diff --git a/package-lock.json b/package-lock.json index 079a4294..7c6b449e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "@gravity-ui/i18n": "^1.1.0", "@gravity-ui/icons": "^2.4.0", "lodash": "^4.17.21", - "resize-observer-polyfill": "^1.5.1" + "resize-observer-polyfill": "^1.5.1", + "universal-cookie": "^6.1.1" }, "devDependencies": { "@babel/preset-env": "^7.22.6", @@ -5780,6 +5781,11 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", + "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==" + }, "node_modules/@types/cross-spawn": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz", @@ -8940,7 +8946,6 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -23015,6 +23020,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-cookie": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-6.1.1.tgz", + "integrity": "sha512-33S9x3CpdUnnjwTNs2Fgc41WGve2tdLtvaK2kPSbZRc5pGpz2vQFbRWMxlATsxNNe/Cy8SzmnmbuBM85jpZPtA==", + "dependencies": { + "@types/cookie": "^0.5.1", + "cookie": "^0.5.0" + } + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/package.json b/package.json index 90c4c30e..96e7f307 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "@gravity-ui/i18n": "^1.1.0", "@gravity-ui/icons": "^2.4.0", "lodash": "^4.17.21", - "resize-observer-polyfill": "^1.5.1" + "resize-observer-polyfill": "^1.5.1", + "universal-cookie": "^6.1.1" }, "devDependencies": { "@babel/preset-env": "^7.22.6", diff --git a/src/components/CookieConsent/ConsentManager.ts b/src/components/CookieConsent/ConsentManager.ts new file mode 100644 index 00000000..6e4941e0 --- /dev/null +++ b/src/components/CookieConsent/ConsentManager.ts @@ -0,0 +1,164 @@ +import _ from 'lodash'; +import Cookies from 'universal-cookie'; +import type {CookieSetOptions} from 'universal-cookie/cjs/types'; + +export const COOKIE_NAME = 'analyticsConsents'; +export const CONSENT_COOKIE_SETTINGS: CookieSettings = { + path: '/', + maxAge: 60 * 60 * 24 * 365, + secure: true, + sameSite: true, +}; + +export enum ConsentType { + Necessary = 'necessary', + Analytics = 'analytics', + Marketing = 'marketing', +} + +export enum ConsentMode { + Notification = 'notification', + OptIn = 'opt-in', + Base = 'base', +} + +export enum AdditionalConsentParams { + Closed = 'closed', + Edition = 'edition', +} + +export type Consents = { + [k in ConsentType | AdditionalConsentParams]?: boolean | number; +}; + +export type CookieSettings = CookieSetOptions; + +const cookies = new Cookies(); + +type Subscriber = (changedConsents: Consents, allConsents: Consents) => void; + +export class ConsentManager { + readonly mode: ConsentMode; + private consentEdition: number | undefined; + private projectConsentEdition: number | undefined; + + private closed = false; + private consents: Consents = {}; + private cookiesTypes: Array = Object.values(ConsentType); + private readonly subscribers: Subscriber[] = []; + private readonly cookieSettings: CookieSettings = CONSENT_COOKIE_SETTINGS; + + constructor(mode: ConsentMode, edition?: number) { + this.mode = mode; + this.projectConsentEdition = edition; + + this.setInitValues(); + } + + get cookies() { + return this.cookiesTypes; + } + + get cookiesSettings() { + return this.cookieSettings; + } + + get edition() { + return this.consentEdition; + } + + get projectCEdition() { + return this.projectConsentEdition; + } + + isClosed(): boolean { + return this.closed; + } + + setInitValues() { + const value = cookies.get(COOKIE_NAME); + + if (!(typeof value === 'object' && !Array.isArray(value) && value)) { + return; + } + + this.consents = { + ..._.pick(value, Object.values(ConsentType)), + }; + + if (value[AdditionalConsentParams.Closed]) { + this.closed = true; + } + + if (value[AdditionalConsentParams.Edition]) { + this.consentEdition = value.edition; + } + } + + subscribe(handler: Subscriber) { + this.subscribers.push(handler); + + return () => { + const index = this.subscribers.findIndex((value) => value === handler); + if (index >= 0) { + this.subscribers.splice(index, 1); + } + }; + } + + setConsents(consents: Consents) { + const difference = Object.values(this.cookiesTypes).filter( + (type) => !consents[type] || consents[type] !== this.consents[type], + ); + const differenceInVersion = this.consentEdition !== this.projectConsentEdition; + const shouldClose = this.mode === ConsentMode.Notification && !this.closed; + + if (!difference.length && !differenceInVersion && !shouldClose) { + return; + } + + Object.assign(this.consents, consents); + + this.saveNewCookieValue(); + this.handleConsentChange(_.pick(consents, difference)); + } + + saveNewCookieValue() { + let newValue: Consents = { + ...this.consents, + [AdditionalConsentParams.Edition]: this.projectConsentEdition, + }; + + if (this.mode === ConsentMode.Notification) { + newValue = { + ...newValue, + [AdditionalConsentParams.Closed]: true, + }; + } + + cookies.set(COOKIE_NAME, newValue, this.cookieSettings); + } + + getConsents() { + return {...this.consents}; + } + + isAllConsentsDefined() { + return Object.values(this.cookiesTypes).every( + (type) => typeof this.consents[type] === 'boolean', + ); + } + + isAllConsentsAccepted() { + return Object.values(this.cookiesTypes).every((type) => this.consents[type]); + } + + setCookieSettings(settings: Partial) { + Object.assign(this.cookieSettings, settings); + } + + private handleConsentChange(changedConsents: Consents) { + const allConsents = this.getConsents(); + this.subscribers.forEach((handler) => handler(changedConsents, allConsents)); + } +} diff --git a/src/components/CookieConsent/CookieConsent.tsx b/src/components/CookieConsent/CookieConsent.tsx new file mode 100644 index 00000000..e7f47c51 --- /dev/null +++ b/src/components/CookieConsent/CookieConsent.tsx @@ -0,0 +1,100 @@ +import React, {useEffect} from 'react'; + +import {block} from '../utils/cn'; + +import {ConsentMode} from './ConsentManager'; +import {ConsentNotification} from './components/ConsentNotification/ConsentNotification'; +import {ConsentPopup} from './components/ConsentPopup/ConsentPopup'; +import type {ConsentPopupProps} from './components/ConsentPopup/types'; +import {SimpleConsent} from './components/SimpleConsent/SimpleConsent'; +import {CookieConsentBaseProps, CookieConsentProps} from './types'; + +const b = block('analytics'); + +export const CookieConsent: React.FC = ({ + consentManager, + onConsentPopupClose, + disableInitialOpen, + ...popupProps +}) => { + const [isOpened, setIsOpened] = React.useState(false); + const isNotificationMode = consentManager.mode === ConsentMode.Notification; + + useEffect(() => { + // Show banner after some timeout so that the user has time to see the service content + const timeoutId = setTimeout(() => { + const isConsentsDefined = consentManager.isAllConsentsDefined(); + const shouldOpen = isNotificationMode + ? !consentManager.isClosed() || !isConsentsDefined + : !isConsentsDefined; + const differentEdition = consentManager.projectCEdition !== consentManager.edition; + + if (!disableInitialOpen && (shouldOpen || differentEdition)) { + setIsOpened(true); + } + }, 1000); + + return () => clearTimeout(timeoutId); + }, [disableInitialOpen]); + + useEffect(() => { + if (!isNotificationMode) { + consentManager.subscribe(() => { + setIsOpened(!consentManager.isAllConsentsDefined()); + }); + } + }, [consentManager, isNotificationMode]); + + const onConsentPopupAction = React.useCallback( + (consents) => consentManager.setConsents(consents), + [consentManager], + ); + + const onClose = React.useCallback(() => { + setIsOpened(false); + onConsentPopupClose?.(); + }, [setIsOpened, onConsentPopupClose]); + + const forceOpen = + !isOpened && + consentManager.mode === ConsentMode.OptIn && + (popupProps as ConsentPopupProps).forceOpenManageStep; + + if (isOpened || forceOpen) { + switch (consentManager.mode) { + case ConsentMode.OptIn: + return ( + + ); + case ConsentMode.Notification: + return ( + + ); + case ConsentMode.Base: + return ( + + ); + } + } + + return null; +}; diff --git a/src/components/CookieConsent/README.md b/src/components/CookieConsent/README.md new file mode 100644 index 00000000..0ec389a0 --- /dev/null +++ b/src/components/CookieConsent/README.md @@ -0,0 +1,94 @@ +# CookieConsent + +## Usage + +```tsx +import React from 'react'; +import {CookieConsent} from '@gravity-ui/components'; + +const consentManager = new ConsentManager(ConsentMode.OptIn); + +... + +useMount(() => { + consentManager.subscribe(onUpdateConsent); +}); + +... + +const consent = ( + +); +``` + +## Props + +```ts +type CookieConsentComponentProps = + | ConsentNotificationData + | ConsentPopupData + | SimpleConsentData; + +type CookieConsentProps = CookieConsentComponentProps & { + consentManager: ConsentManager; + onConsentPopupClose?: () => void; + /* Don't show popup under certain conditions */ + disableInitialOpen?: boolean; +}; + +type ConsentNotificationData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Text on the consent acceptance button */ + buttonOkText?: string; + /* Is mobile view */ + isMobile?: boolean; +} + +type ConsentPopupData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Is mobile view */ + isMobile?: boolean; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; + /* Text on the button for accepting required cookies */ + buttonNecessaryText?: string; + /* Text on the button to confirm the choice */ + buttonConfirmText?: string; + /* Text about cookie management */ + manageLabelText?: string; + /* Active step */ + step?: ConsentPopupStep; + cookieList?: ConsentPopupCookieListItem[]; + /* To open by the link or button, so the popup should not be opened at this moment */ + forceOpenManageStep?: boolean; +} + +type SimpleConsentData { + /* Content */ + text?: string; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; +} +``` diff --git a/src/components/CookieConsent/__stories__/CookieConsent.stories.tsx b/src/components/CookieConsent/__stories__/CookieConsent.stories.tsx new file mode 100644 index 00000000..dd079a8f --- /dev/null +++ b/src/components/CookieConsent/__stories__/CookieConsent.stories.tsx @@ -0,0 +1,80 @@ +import React, {useMemo} from 'react'; + +import {Meta, StoryFn} from '@storybook/react'; + +import {ConsentManager, ConsentMode, ConsentType, Consents} from '../ConsentManager'; +import type {CookieConsentProps} from '../CookieConsent'; +import {ConsentPopupCookieListItem, ConsentPopupStep} from '../components/ConsentPopup/types'; +import {CookieConsent} from '../index'; + +export default { + title: 'Components/CookieConsent', + component: CookieConsent, +} as Meta; + +const cookieList = Object.values(ConsentType).map((type) => { + const result: ConsentPopupCookieListItem = { + type, + link: {href: 'https://google.com'}, + }; + + if (type === ConsentType.Necessary) { + result.titleLabel = 'Always active'; + } + + return result; +}); + +type DefaultTemplateProps = Omit & {consentMode: ConsentMode}; + +const DefaultTemplate: StoryFn = ({consentMode, ...args}) => { + const consentManager: ConsentManager = useMemo( + () => new ConsentManager(consentMode), + [consentMode], + ); + return ; +}; + +export const SimpleConsent = DefaultTemplate.bind({}); +SimpleConsent.args = { + policyLink: 'https://google.com', + onAction: (constents: Consents) => console.log(constents), + consentMode: ConsentMode.Base, +}; + +export const ConsentPopup = DefaultTemplate.bind({}); +ConsentPopup.args = { + onAction: (constents: Consents) => console.log(constents), + onClose: () => {}, + consentMode: ConsentMode.OptIn, + policyLink: 'https://google.com', + cookieList, +} as DefaultTemplateProps; + +export const ConsentPopupManageStep = DefaultTemplate.bind({}); +ConsentPopupManageStep.args = { + onAction: (constents: Consents) => console.log(constents), + onClose: () => {}, + consentMode: ConsentMode.OptIn, + step: ConsentPopupStep.Manage, + policyLink: 'https://google.com', + cookieList, +} as DefaultTemplateProps; + +export const ConsentNotification = DefaultTemplate.bind({}); +ConsentNotification.args = { + onAction: (constents: Consents) => console.log(constents), + onClose: () => {}, + consentMode: ConsentMode.Notification, + policyLink: 'https://google.com', +} as DefaultTemplateProps; + +export const ConsentNotificationMobile = DefaultTemplate.bind({}); +ConsentNotificationMobile.args = { + onAction: (constents: Consents) => console.log(constents), + onClose: () => {}, + consentMode: ConsentMode.Notification, + isMobile: true, + policyLinkText: 'Cookie Policy', + policyLink: 'https://google.com', +} as DefaultTemplateProps; diff --git a/src/components/CookieConsent/components/Collapse/Collapse.scss b/src/components/CookieConsent/components/Collapse/Collapse.scss new file mode 100644 index 00000000..fa6e7a93 --- /dev/null +++ b/src/components/CookieConsent/components/Collapse/Collapse.scss @@ -0,0 +1,43 @@ +@use '../../../variables'; +@use '../../../mixins'; + +.yc-collapse { + &__header { + display: inline-flex; + align-items: center; + } + + &__panel { + @include mixins.button-reset(); + @include mixins.focusable(); + display: inline-flex; + align-items: center; + border-radius: var(--g-focus-border-radius); + + cursor: pointer; + } + + &__title { + margin: 0; + @include mixins.text-body-3; + } + + &__title_secondary { + color: var(--g-color-text-secondary); + } + + &__arrow-wrapper { + margin-left: 8px; + } + + &__arrow-wrapper_secondary { + color: var(--g-color-text-secondary); + .yc-button__text { + color: var(--g-color-text-secondary); + } + } + + &_collapsed &__content { + display: none; + } +} diff --git a/src/components/CookieConsent/components/Collapse/Collapse.tsx b/src/components/CookieConsent/components/Collapse/Collapse.tsx new file mode 100644 index 00000000..203363f4 --- /dev/null +++ b/src/components/CookieConsent/components/Collapse/Collapse.tsx @@ -0,0 +1,115 @@ +import React, {useCallback, useEffect, useState} from 'react'; + +import {ArrowToggle, useUniqId} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; + +import {CollapseProps, CollapseTogglerProps} from './types'; + +import './Collapse.scss'; + +const b = block('collapse'); + +const arrowSize = 20; + +export function CollapseToggler({isSecondary, isExpand}: CollapseTogglerProps) { + const arrowDirection = isExpand ? 'top' : 'bottom'; + + return ( +
+ +
+ ); +} + +export function Collapse({ + className, + headerClassName, + title, + titleClassName, + children, + toolbar, + toolbarClassName, + contentMarginTop = 16, + defaultIsExpand = false, + isSecondary = false, + beforeExpandChange, + isExpand: isControlledExpand, + cacheContent, +}: CollapseProps) { + const [isExpand, setIsExpand] = useState(isControlledExpand || defaultIsExpand); + const [contentMounted, setContentMounted] = useState(false); + + useEffect(() => { + if (isControlledExpand !== undefined) { + beforeExpandChange?.(isControlledExpand); + setIsExpand(isControlledExpand); + } + }, [beforeExpandChange, isControlledExpand]); + + const toggleExpand = useCallback(() => { + const newExpandValue = !isExpand; + + beforeExpandChange?.(newExpandValue); + + setIsExpand(newExpandValue); + }, [setIsExpand, isExpand, beforeExpandChange]); + + const initContent = useCallback((element: HTMLDivElement | null) => { + if (element !== null) { + setContentMounted(true); + } + }, []); + const contentId = useUniqId(); + + const shouldRenderContent = children && (isExpand || (cacheContent && contentMounted)); + + return ( +
+
+ + + {toolbar &&
{toolbar}
} +
+ + {shouldRenderContent && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/components/CookieConsent/components/Collapse/types.ts b/src/components/CookieConsent/components/Collapse/types.ts new file mode 100644 index 00000000..1780e584 --- /dev/null +++ b/src/components/CookieConsent/components/Collapse/types.ts @@ -0,0 +1,30 @@ +export interface CollapseProps { + className?: string; + headerClassName?: string; + /** The content that will appear when the line is expanded */ + children?: React.ReactNode; + /** The content that will be shown in the title, to the right of the title and the opening/closing arrows */ + toolbar?: React.ReactNode; + toolbarClassName?: string; + /** The header of the collapse line */ + title: string | JSX.Element; + titleClassName?: string; + /** The initial state of expand */ + defaultIsExpand?: boolean; + /** Indentation at the top of the content block */ + contentMarginTop?: string | number; + /** Use secondary-color */ + isSecondary?: boolean; + /** Event triggered before hiding/revealing */ + beforeExpandChange?: (isExpand: boolean) => void; + /** The disclosure state of the component, which is controlled from an external component */ + isExpand?: boolean; + /** Do we need to cache the collapsible content and leave it in the DOM after the first rendering */ + cacheContent?: boolean; +} + +export interface CollapseTogglerProps { + isExpand: boolean; + isSecondary: boolean; + contentId?: string; +} diff --git a/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.scss b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.scss new file mode 100644 index 00000000..b7f65aba --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.scss @@ -0,0 +1,59 @@ +@use '@gravity-ui/uikit/styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}consent-notification'; + +#{$block} { + @include mixins.text-body-2; + + position: fixed; + box-sizing: border-box; + background-color: var(--g-color-base-float-announcement); + + z-index: 1000; + + &__text { + display: block; + + & + & { + margin-top: variables.$regularOffset; + } + } + + &_type_default { + &#{$block} { + right: variables.$regularOffset; + bottom: variables.$regularOffset; + width: 480px; + border-radius: var(--g-border-radius-xl); + padding: variables.$doubleRegularOffset; + box-shadow: 0px variables.$microOffset variables.$normalOffset rgba(0, 0, 0, 0.15); + } + + #{$block} { + &__text { + line-height: variables.$doubleInlineOffset; + } + + &__button { + margin-top: variables.$microOffset; + padding: 0 variables.$regularOffset; + align-self: flex-end; + } + } + } + + &_type_mobile { + &#{$block} { + left: 0; + bottom: 0; + width: 100%; + padding: variables.$normalOffset; + } + + #{$block}__button { + width: 100%; + margin-top: variables.$normalOffset; + } + } +} diff --git a/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.tsx b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.tsx new file mode 100644 index 00000000..4c81a28b --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/ConsentNotification.tsx @@ -0,0 +1,56 @@ +import React, {useCallback} from 'react'; + +import {Alert, Button} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; +import {prepareConsent} from '../../helpers'; +import i18n from '../../i18n'; +import {PolicyLink} from '../PolicyLink/PolicyLink'; + +import {ConsentNotificationProps} from './types'; + +import './ConsentNotification.scss'; + +const b = block('consent-notification'); + +export const ConsentNotification: React.FC = ({ + policyLink, + onAction, + className, + policyLinkText = i18n('label_policy'), + text = i18n('label_text'), + buttonOkText = i18n('button_OK'), + consentManager, + isMobile, + onClose, +}) => { + const onClick = useCallback(() => { + onAction(prepareConsent(true, consentManager.cookies)); + onClose?.(); + }, [onAction, consentManager, onClose]); + const message = ( + <> + {text} + {policyLink ? ( + + {i18n('details_text')} . + + ) : null} + + ); + const actions = ( + + ); + + return ( + + ); +}; diff --git a/src/components/CookieConsent/components/ConsentNotification/index.ts b/src/components/CookieConsent/components/ConsentNotification/index.ts new file mode 100644 index 00000000..fb289af7 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './ConsentNotification'; diff --git a/src/components/CookieConsent/components/ConsentNotification/types.ts b/src/components/CookieConsent/components/ConsentNotification/types.ts new file mode 100644 index 00000000..52c8844b --- /dev/null +++ b/src/components/CookieConsent/components/ConsentNotification/types.ts @@ -0,0 +1,16 @@ +import {CookieConsentBaseProps} from '../../types'; + +export interface ConsentNotificationData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Text on the consent acceptance button */ + buttonOkText?: string; + /* Is mobile view */ + isMobile?: boolean; +} + +export type ConsentNotificationProps = ConsentNotificationData & CookieConsentBaseProps; diff --git a/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.scss b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.scss new file mode 100644 index 00000000..2d0c600f --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.scss @@ -0,0 +1,119 @@ +@use '@gravity-ui/uikit/styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}consent-popup'; + +#{$block} { + @include mixins.text-body-2; + + padding: variables.$doubleRegularOffset; + border-radius: 14px; + max-width: calc(720px - #{variables.$doubleRegularOffset} * 2); + + &__header { + display: flex; + justify-content: space-between; + } + + &__title { + font-weight: var(--g-text-header-font-weight); + } + + &__body { + margin: variables.$doubleInlineOffset 0 48px; + line-height: variables.$doubleInlineOffset; + + &_step_manage { + margin-bottom: variables.$doubleRegularOffset; + } + } + + &__text { + & + & { + margin-top: variables.$regularOffset; + } + } + + &__link { + color: var(--g-color-text-link); + text-decoration: none; + cursor: pointer; + white-space: nowrap; + + &:hover, + &:active { + color: var(--g-color-text-link-hover); + } + } + + &__buttons { + display: flex; + justify-content: flex-end; + } + + &__button + &__button { + margin-left: variables.$regularOffset; + } + + &__close-button { + --yc-button-outline-color: var(--g-color-line-focus); + + position: relative; + top: 1px; + } + + &__arrow-button { + --yc-button-outline-color: var(--g-color-line-focus); + + position: relative; + top: -2px; + } + + &__cookie-list { + margin-top: variables.$doubleInlineOffset; + } + + &__modal-content_mobile { + #{$block} { + &__body { + margin: variables.$microOffset 0 variables.$regularOffset; + line-height: 20px; + } + + &__buttons { + justify-content: flex-start; + } + + &__button + &__button { + margin-left: variables.$inlineOffset; + } + + &__arrow-button { + margin-right: variables.$regularOffset; + } + } + + &#{$block}__modal-content_step_manage { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: 0; + overflow-y: scroll; + border-radius: 0; + + #{$block} { + padding: variables.$doubleRegularOffset variables.$doubleInlineOffset; + + &__body { + margin: variables.$doubleRegularOffset 0; + } + + &__text + &__text { + margin-top: variables.$microOffset; + } + } + } + } +} diff --git a/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.tsx b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.tsx new file mode 100644 index 00000000..053db471 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/ConsentPopup.tsx @@ -0,0 +1,277 @@ +import React, {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; + +import {ArrowLeft, Xmark} from '@gravity-ui/icons'; +import {Button, Icon, Modal, Text} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; +import {ConsentType} from '../../ConsentManager'; +import type {ConsentManager, Consents} from '../../ConsentManager'; +import {prepareConsent} from '../../helpers'; +import i18n from '../../i18n'; +import {FoldableList} from '../FoldableList/FoldableList'; +import {PolicyLink} from '../PolicyLink/PolicyLink'; + +import { + ConsentPopupCookieListItem, + ConsentPopupProps, + ConsentPopupStep, + FooterProps, + HeaderProps, +} from './types'; + +import './ConsentPopup.scss'; + +const b = block('consent-popup'); + +const getCurrentConsents = (consentManager: ConsentManager) => { + const constents = consentManager.getConsents(); + + if (Object.keys(constents).length) { + return constents; + } + + return { + [ConsentType.Necessary]: true, + }; +}; + +const Header: React.FC = ({ + currentStep, + initialStep, + onClose, + onChangeStep, + isMobile, +}) => { + const buttonsEnabled = currentStep === ConsentPopupStep.Manage; + const isBackButtonVisible = buttonsEnabled && initialStep === ConsentPopupStep.Main; + + return ( +
+
+ {isBackButtonVisible ? ( + + ) : null} + + {i18n(buttonsEnabled ? 'label_title_manage' : 'label_title_main')} + +
+ {buttonsEnabled && !isBackButtonVisible ? ( + + ) : null} +
+ ); +}; + +const Footer: React.FC = ({ + onAction, + onClose, + currentStep, + currentConsents, + cookiesTypes, + buttonAcceptText = i18n('button_accept_all'), + buttonNecessaryText = i18n('button_necessary'), + buttonConfirmText = i18n('button_confirm'), +}) => { + const isManageStep = currentStep === ConsentPopupStep.Manage; + const onButtonClick = useCallback( + (onlyNecessary?: boolean) => { + return () => { + onAction(prepareConsent(true, cookiesTypes, onlyNecessary)); + onClose?.(); + }; + }, + [onAction, onClose], + ); + const confirmSelectedConsent = useCallback(() => { + onAction(currentConsents); + onClose?.(); + }, [onAction, onClose, currentConsents]); + + return ( +
+ + +
+ ); +}; + +export const ConsentPopup: React.FC = ({ + policyLink, + onAction, + className, + policyLinkText = i18n('label_policy_extended'), + text, + manageLabelText = i18n('manage_label_text_extended'), + step = ConsentPopupStep.Main, + cookieList, + isMobile, + onClose, + consentManager, + ...buttonsParams +}) => { + const [currentConsents, setCurrentConsents] = useState(() => { + return getCurrentConsents(consentManager); + }); + const [currentStep, setCurrentStep] = useState(step); + const onChangeStep = useCallback( + (step: ConsentPopupStep) => { + return () => setCurrentStep(step); + }, + [setCurrentStep], + ); + const isManageStep = currentStep === ConsentPopupStep.Manage; + const preparedCookieList = useMemo(() => { + return cookieList?.map((item) => { + const isNecessaryItem = item.type === ConsentType.Necessary; + + return { + checked: Boolean(currentConsents[item.type]), + disabled: isNecessaryItem, + defaultExpand: isNecessaryItem, + title: item.title || i18n(`cookie_${item.type}_title`), + text: item.text || i18n(`cookie_${item.type}_text`), + link: item.link + ? { + href: item.link?.href, + title: item.link?.title || i18n(`cookie_link_text`), + } + : undefined, + titleLabel: item.titleLabel, + }; + }); + }, [cookieList, currentConsents]); + const onChoose = useCallback( + (checkedItems: number[]) => { + if (!cookieList) return; + + setCurrentConsents( + cookieList.reduce( + (acc: Consents, item: ConsentPopupCookieListItem, index: number) => { + acc[item.type] = checkedItems.includes(index); + + return acc; + }, + {}, + ), + ); + }, + [setCurrentConsents, cookieList], + ); + + useEffect(() => { + setCurrentConsents(getCurrentConsents(consentManager)); + }, [consentManager.cookies, setCurrentConsents]); + + return ( + +
+
+
+ {isManageStep ? ( + + + {i18n('manage_subtitle_extended')} + +
+ {manageLabelText} + {policyLink && policyLinkText && ( + + {' '} + + + )} + . +
+ {preparedCookieList ? ( + + ) : null} +
+ ) : ( + +
+ +
+
+ {i18n('label_manage_cookie')}{' '} + + {i18n('label_manage_cookie_link_text')} + + . +
+
+ )} +
+
+
+
+ ); +}; diff --git a/src/components/CookieConsent/components/ConsentPopup/index.ts b/src/components/CookieConsent/components/ConsentPopup/index.ts new file mode 100644 index 00000000..b6c305e2 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './ConsentPopup'; diff --git a/src/components/CookieConsent/components/ConsentPopup/types.ts b/src/components/CookieConsent/components/ConsentPopup/types.ts new file mode 100644 index 00000000..e215b1e2 --- /dev/null +++ b/src/components/CookieConsent/components/ConsentPopup/types.ts @@ -0,0 +1,72 @@ +import type {ConsentType, Consents} from '../../ConsentManager'; +import {CookieConsentBaseProps} from '../../types'; +import {FoldableListItem} from '../FoldableList/types'; + +export enum ConsentPopupStep { + /* Step with base info */ + Main = 'main', + /* Step with cookies settings */ + Manage = 'manage', +} + +export interface ConsentPopupCookieListItem extends Pick { + type: ConsentType; + title?: string; + text?: string; +} + +export interface ConsentPopupData { + /* Content */ + text?: string; + /* Link to the privacy policy */ + policyLink?: string; + /* Text for the link to the privacy policy */ + policyLinkText?: string; + /* Is mobile view */ + isMobile?: boolean; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; + /* Text on the button for accepting required cookies */ + buttonNecessaryText?: string; + /* Text on the button to confirm the choice */ + buttonConfirmText?: string; + /* Text about cookie management */ + manageLabelText?: string; + /* Active step */ + step?: ConsentPopupStep; + cookieList?: ConsentPopupCookieListItem[]; + /* To open by the link or button, so the popup should not be opened at this moment */ + forceOpenManageStep?: boolean; +} + +export type ConsentPopupProps = ConsentPopupData & CookieConsentBaseProps; + +export interface HeaderProps { + /* Active step */ + currentStep: ConsentPopupStep; + /* Initial step */ + initialStep: ConsentPopupStep; + onClose: () => void; + onChangeStep: (step: ConsentPopupStep) => () => void; + /* Is mobile view */ + isMobile?: boolean; +} + +export interface FooterProps { + /* Active step */ + currentStep: ConsentPopupStep; + onClose: () => void; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the button for accepting required cookies */ + buttonNecessaryText?: string; + /* Text on the button to confirm the choice */ + buttonConfirmText?: string; + onAction: (consents: Consents) => void; + /* Current consent */ + currentConsents: Consents; + /* List with types of cookies */ + cookiesTypes: ConsentType[]; +} diff --git a/src/components/CookieConsent/components/FoldableList/FoldableList.scss b/src/components/CookieConsent/components/FoldableList/FoldableList.scss new file mode 100644 index 00000000..b6fe1842 --- /dev/null +++ b/src/components/CookieConsent/components/FoldableList/FoldableList.scss @@ -0,0 +1,98 @@ +@use '../../../mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}foldable-list'; + +#{$block} { + $border-color: var(--g-color-private-cool-grey-100); + + @include mixins.text-body-2(); + + &__item { + border-top: 1px solid $border-color; + padding: variables.$regularOffset variables.$microOffset; + + &:last-child { + border-bottom: 1px solid $border-color; + } + } + + &__item-header, + &__item-title-wrapper { + width: 100%; + } + + &__item-title-wrapper { + @include mixins.focusable(0, 'box-shadow'); + display: inline-flex; + justify-content: space-between; + background: none; + border: none; + padding: 0; + } + + &__item-title { + display: flex; + align-items: center; + } + + &__title-checkbox { + margin-right: variables.$inlineOffset; + } + + &__title-label { + margin-left: variables.$microOffset; + padding: 1px variables.$microOffset; + background: var(--g-color-private-cool-grey-50); + color: var(--g-color-private-cool-grey-800); + border-radius: 5px; + } + + &__content-text { + line-height: variables.$doubleInlineOffset; + } + + &__content-link { + display: flex; + align-items: center; + margin-top: variables.$regularOffset; + } + + &__item_mobile { + #{$block} { + &__title { + display: flex; + flex-direction: column-reverse; + align-items: flex-start; + } + + &__title-text { + font-size: variables.$normalOffset; + line-height: variables.$doubleInlineOffset; + } + + &__title-label { + margin-left: 0; + margin-bottom: variables.$microOffset; + } + + &__title-checkbox { + margin-right: variables.$regularOffset; + } + + &__item-title, + &__item-title-wrapper { + align-items: flex-start; + } + + &__content { + margin-left: variables.$doubleRegularOffset; + } + } + + &#{$block}__item { + padding: variables.$doubleRegularOffset variables.$regularOffset + variables.$doubleRegularOffset 0; + } + } +} diff --git a/src/components/CookieConsent/components/FoldableList/FoldableList.tsx b/src/components/CookieConsent/components/FoldableList/FoldableList.tsx new file mode 100644 index 00000000..bc9aaefb --- /dev/null +++ b/src/components/CookieConsent/components/FoldableList/FoldableList.tsx @@ -0,0 +1,114 @@ +import React, {useCallback, useState} from 'react'; + +import {ChevronRight} from '@gravity-ui/icons'; +import {Checkbox, Icon, Link, Text} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; +import {Collapse} from '../Collapse/Collapse'; + +import {FoldableListItem, FoldableListProps} from './types'; + +import './FoldableList.scss'; + +const b = block('foldable-list'); + +export const FoldableList = (props: FoldableListProps) => { + const {items, className, isMobile, onChooseItem} = props; + const [checkedItems, setChecked] = useState(() => + items.reduce((acc: number[], item: FoldableListItem, index: number) => { + if (item.checked) { + acc.push(index); + } + + return acc; + }, []), + ); + + const onCheckItem = useCallback( + (index: number) => { + return () => { + let newState; + + if (checkedItems.includes(index)) { + newState = checkedItems.filter((intemIndex: number) => intemIndex !== index); + } else { + newState = [...checkedItems, index]; + } + + onChooseItem?.(newState); + setChecked(newState); + }; + }, + [checkedItems, setChecked, onChooseItem], + ); + + return ( +
+ {items.map( + ({title, titleLabel, text, link, checked, disabled, defaultExpand}, index) => { + const isChecked = checkedItems.includes(index); + + return ( + + ) => { + event.stopPropagation(); + }} + > + + +
+ + {title} + + {titleLabel ? ( + + {titleLabel} + + ) : null} +
+
+ } + > +
+ + {text} + + {link ? ( + + {link.title} + + + ) : null} +
+ + ); + }, + )} + + ); +}; diff --git a/src/components/CookieConsent/components/FoldableList/types.ts b/src/components/CookieConsent/components/FoldableList/types.ts new file mode 100644 index 00000000..ec50fed3 --- /dev/null +++ b/src/components/CookieConsent/components/FoldableList/types.ts @@ -0,0 +1,25 @@ +import type {LinkProps} from '@gravity-ui/uikit'; + +export interface FoldableListItem { + /* Title */ + title: string; + /* Text in the hidden part */ + text: string; + /* Label, it is locates near the title */ + titleLabel?: string; + /* Link is locates at the end of the hidden part */ + link?: Pick; + /* Inintial expand */ + defaultExpand?: boolean; + /* Inintial check */ + checked?: boolean; + /* Inintial disable */ + disabled?: boolean; +} + +export interface FoldableListProps { + items: FoldableListItem[]; + className?: string; + onChooseItem?: (checkedItems: number[]) => void; + isMobile?: boolean; +} diff --git a/src/components/CookieConsent/components/PolicyLink/PolicyLink.scss b/src/components/CookieConsent/components/PolicyLink/PolicyLink.scss new file mode 100644 index 00000000..d970a6c6 --- /dev/null +++ b/src/components/CookieConsent/components/PolicyLink/PolicyLink.scss @@ -0,0 +1,18 @@ +@use '../../../mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}policy-link'; + +#{$block} { + @include mixins.focusable(0, 'box-shadow'); + + color: var(--g-color-text-link); + text-decoration: none; + cursor: pointer; + white-space: nowrap; + + &:hover, + &:active { + color: var(--g-color-text-link-hover); + } +} diff --git a/src/components/CookieConsent/components/PolicyLink/PolicyLink.tsx b/src/components/CookieConsent/components/PolicyLink/PolicyLink.tsx new file mode 100644 index 00000000..eff8d700 --- /dev/null +++ b/src/components/CookieConsent/components/PolicyLink/PolicyLink.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import {block} from '../../../utils/cn'; + +import './PolicyLink.scss'; + +export interface PolicyLinkProps { + link: string; + text: string; +} + +const b = block('policy-link'); + +export const PolicyLink: React.FC = ({link, text}) => ( + + {text} + +); diff --git a/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.scss b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.scss new file mode 100644 index 00000000..90fe8ee0 --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.scss @@ -0,0 +1,62 @@ +@use '@gravity-ui/uikit/styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns}simple-consent'; + +#{$block} { + $breakpoint: 769px; + $indent: 24px; + + @include mixins.text-body-2; + + position: fixed; + bottom: $indent; + left: 0; + max-width: calc(1232px + variables.$bigOffset * 2); + padding: $indent 32px; + margin: 0 variables.$bigOffset; + box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.1); + + background-color: var(--g-color-base-float); + border-radius: var(--g-border-radius-xl); + + z-index: 2000; + + &__container { + display: flex; + justify-content: center; + align-items: center; + } + + &__buttons { + display: flex; + } + + &__button { + &#{&} { + margin: 0 0 0 variables.$regularOffset; + } + } + + @media (max-width: $breakpoint) { + &__buttons { + width: 100%; + } + + &__container { + flex-direction: column; + } + + &__button { + flex: 1 1 0; + + &#{&} { + margin-top: $indent; + + &:first-child { + margin-left: 0; + } + } + } + } +} diff --git a/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.tsx b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.tsx new file mode 100644 index 00000000..1ccfcb26 --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/SimpleConsent.tsx @@ -0,0 +1,59 @@ +import React, {useCallback} from 'react'; + +import {Button, Portal} from '@gravity-ui/uikit'; + +import {block} from '../../../utils/cn'; +import {prepareConsent} from '../../helpers'; +import i18n from '../../i18n'; + +import {SimpleConsentProps} from './types'; + +import './SimpleConsent.scss'; + +const b = block('simple-consent'); +const buttons = ['decline', 'accept'] as const; + +export const SimpleConsent: React.FC = (props) => { + const { + className, + text = i18n('label_text'), + buttonAcceptText = i18n('button_accept'), + buttonDeclineText = i18n('button_decline'), + consentManager, + onAction, + onClose, + } = props; + + const onClick = useCallback( + (value: boolean) => { + return () => { + onAction(prepareConsent(value, consentManager.cookies)); + onClose?.(); + }; + }, + [onAction, onClose, consentManager], + ); + + return ( + +
+
+ {text} +
+ {buttons.map((button) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/src/components/CookieConsent/components/SimpleConsent/index.ts b/src/components/CookieConsent/components/SimpleConsent/index.ts new file mode 100644 index 00000000..b64c31f2 --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './SimpleConsent'; diff --git a/src/components/CookieConsent/components/SimpleConsent/types.ts b/src/components/CookieConsent/components/SimpleConsent/types.ts new file mode 100644 index 00000000..10b51785 --- /dev/null +++ b/src/components/CookieConsent/components/SimpleConsent/types.ts @@ -0,0 +1,12 @@ +import {CookieConsentBaseProps} from '../../types'; + +export interface SimpleConsentData { + /* Content */ + text?: string; + /* Text on the consent acceptance button */ + buttonAcceptText?: string; + /* Text on the reject button of the consent */ + buttonDeclineText?: string; +} + +export type SimpleConsentProps = SimpleConsentData & CookieConsentBaseProps; diff --git a/src/components/CookieConsent/helpers.ts b/src/components/CookieConsent/helpers.ts new file mode 100644 index 00000000..9788af3e --- /dev/null +++ b/src/components/CookieConsent/helpers.ts @@ -0,0 +1,12 @@ +import {ConsentType, Consents} from './ConsentManager'; + +export const prepareConsent = ( + value: boolean, + cookiesTypes: ConsentType[], + onlyNecessary?: boolean, +) => + cookiesTypes.reduce((acc: Consents, type: ConsentType) => { + acc[type] = onlyNecessary ? type === ConsentType.Necessary : value; + + return acc; + }, {}); diff --git a/src/components/CookieConsent/i18n/en.json b/src/components/CookieConsent/i18n/en.json new file mode 100644 index 00000000..bdbed745 --- /dev/null +++ b/src/components/CookieConsent/i18n/en.json @@ -0,0 +1,26 @@ +{ + "button_OK": "OK", + "button_accept": "Accept", + "button_decline": "Decline", + "button_accept_all": "Accept all", + "button_confirm": "Confirm my choices", + "button_necessary": "Required only", + "cookie_analytics_text": "These cookies allow us to count visits and traffic sources so we can measure and improve the performance of our website. They help us to know which pages are the most and least popular and see how visitors move around the site. If these cookies are disabled, we will not know when a user has visited our website or be able to monitor the website’s performance.", + "cookie_analytics_title": "Analytics cookies", + "cookie_link_text": "View Cookies", + "cookie_marketing_text": "Marketing cookies are necessary to track visitors across the website and display ads that are relevant.", + "cookie_marketing_title": "Marketing cookies", + "cookie_necessary_text": "These cookies are necessary for the website to function and cannot be switched off in our systems. They are usually only set in response to actions by visitors which amount to a request for services, such as setting privacy preferences, logging in or filling out forms. You can set your browser to block or alert you about these cookies, but this may cause parts of the website to not work properly.", + "cookie_necessary_title": "Strictly necessary cookies", + "details_text": "For details, please read our", + "label_manage_cookie": "You can also", + "label_manage_cookie_link_text": "set up cookies your way", + "label_policy": "Privacy Policy", + "label_policy_extended": "Cookie Policy", + "label_text": "By clicking \"Accept\", you consent to our website’s use of Google’s analytics cookies to give you the most relevant experience and for analytics purposes. However, you may \"Decline\" that.", + "label_text_extended": "Site uses cookies to make your browsing secure and fast. To accept all cookies, including analytical, personalization, and advertising, select Allow all. For required cookies only, select Required only. Check our Cookie Policy for details.", + "label_title_main": "This website uses cookies", + "label_title_manage": "General information", + "manage_label_text_extended": "Click on the different category headings to find out more and change our default settings. You can withdraw your consent or manage your consent preferences at any time via the “Manage cookies” link at the footer of our website or you can disable cookies in the settings of your web-browser or mobile device. For more information please see our", + "manage_subtitle_extended": "Cookie Preferences" +} diff --git a/src/components/CookieConsent/i18n/index.ts b/src/components/CookieConsent/i18n/index.ts new file mode 100644 index 00000000..947850d3 --- /dev/null +++ b/src/components/CookieConsent/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeyset} from '../../utils/registerKeyset'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'CookieConsent'; +export default registerKeyset({en, ru}, COMPONENT); diff --git a/src/components/CookieConsent/i18n/ru.json b/src/components/CookieConsent/i18n/ru.json new file mode 100644 index 00000000..9fe51163 --- /dev/null +++ b/src/components/CookieConsent/i18n/ru.json @@ -0,0 +1,26 @@ +{ + "button_OK": "OK", + "button_accept": "Принять", + "button_decline": "Отклонить", + "button_accept_all": "Принять всё", + "button_confirm": "Принять выбранные изменения", + "button_necessary": "Принять только обязательные", + "cookie_analytics_text": "Эти файлы cookie позволяют нам подсчитывать посещения и источники трафика, чтобы мы могли измерять и улучшать производительность нашего веб-сайта. Они помогают нам знать, какие страницы наиболее и наименее популярны, и видеть, как посетители перемещаются по сайту. Если эти файлы cookie отключены, мы не будем знать, когда пользователь посещал наш веб-сайт, и не сможем отслеживать работу веб-сайта.", + "cookie_analytics_title": "Аналитические файлы cookie", + "cookie_link_text": "Посмотреть файлы cookie", + "cookie_marketing_text": "Маркетинговые файлы cookie необходимы для отслеживания посетителей на веб-сайте и показа релевантной рекламы.", + "cookie_marketing_title": "Маркетинговые файлы cookie", + "cookie_necessary_text": "Эти файлы cookie необходимы для функционирования веб-сайта и не могут быть отключены в наших системах. Обычно они устанавливаются только в ответ на действия посетителей, которые представляют собой запрос на услуги, такие как настройка параметров конфиденциальности, вход в систему или заполнение форм. Вы можете настроить свой браузер таким образом, чтобы он блокировал или предупреждал вас об этих файлах cookie, но это может привести к неправильной работе некоторых частей веб-сайта.", + "cookie_necessary_title": "Обязательные файлы cookie", + "details_text": "Детали в ", + "label_manage_cookie": "Вы также можете", + "label_manage_cookie_link_text": "настроить файлы cookie", + "label_policy": "Политике конфиденциальности", + "label_policy_extended": "Политикой конфиденциальности", + "label_text": "Нажав «Принять», вы даете согласие на использование нашим веб-сайтом файлов cookie Google analytics для предоставления вам наиболее релевантных услуг и в аналитических целях. Однако вы можете «Отклонить» это.", + "label_text_extended": "Сайт использует файлы cookie, чтобы сделать ваш просмотр безопасным и быстрым. Чтобы принимать все файлы cookie, включая аналитические, персонализационные и рекламные, выберите Принять всё. Только для обязательных файлов cookie выберите Только обязательные. Ознакомьтесь с нашей Политикой использования файлов cookie для получения подробной информации.", + "label_title_main": "Этот сайт использует файлы cookie", + "label_title_manage": "Основная информация", + "manage_label_text_extended": "Нажмите на заголовки различных категорий, чтобы узнать больше и изменить наши настройки по умолчанию. Вы можете отозвать свое согласие или изменить настройки вашего согласия в любое время, перейдя по ссылке \"Управление файлами cookie\" в нижней части нашего веб-сайта, или вы можете отключить файлы cookie в настройках вашего веб-браузера или мобильного устройства. Для получения дополнительной информации, пожалуйста, ознакомьтесь с нашей", + "manage_subtitle_extended": "Настройки файлов cookie" +} diff --git a/src/components/CookieConsent/index.ts b/src/components/CookieConsent/index.ts new file mode 100644 index 00000000..1ec96dbd --- /dev/null +++ b/src/components/CookieConsent/index.ts @@ -0,0 +1,6 @@ +export {CookieConsent} from './CookieConsent'; +export * from './types'; +export * from './ConsentManager'; +export * from './components/ConsentNotification'; +export * from './components/ConsentPopup'; +export * from './components/SimpleConsent'; diff --git a/src/components/CookieConsent/types.ts b/src/components/CookieConsent/types.ts new file mode 100644 index 00000000..5b021c4e --- /dev/null +++ b/src/components/CookieConsent/types.ts @@ -0,0 +1,23 @@ +import type {ConsentManager, Consents} from './ConsentManager'; +import type {ConsentNotificationData} from './components/ConsentNotification'; +import type {ConsentPopupData} from './components/ConsentPopup'; +import type {SimpleConsentData} from './components/SimpleConsent'; + +export interface CookieConsentBaseProps { + onAction: (consents: Consents) => void; + onClose: () => void; + className?: string; + consentManager: ConsentManager; +} + +export type CookieConsentComponentProps = + | ConsentNotificationData + | ConsentPopupData + | SimpleConsentData; + +export type CookieConsentProps = CookieConsentComponentProps & { + consentManager: ConsentManager; + onConsentPopupClose?: () => void; + // Don't show popup under certain conditions, such as the cookie-policy page + disableInitialOpen?: boolean; +}; diff --git a/src/components/index.ts b/src/components/index.ts index 9640a46b..709e02aa 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export * from './AdaptiveTabs'; +export * from './CookieConsent'; export * from './ChangelogDialog'; export * from './FilePreview'; export * from './FormRow'; diff --git a/src/components/mixins.scss b/src/components/mixins.scss index 1640e361..6c7af52e 100644 --- a/src/components/mixins.scss +++ b/src/components/mixins.scss @@ -1,10 +1,21 @@ @import '@gravity-ui/uikit/styles/mixins'; -@mixin focusable() { - &:focus { - outline: 2px solid var(--g-color-line-focus); +@mixin focusable($offset: 0, $mode: 'outline') { + @if $mode == 'outline' { + &:focus { + outline: 2px solid var(--g-color-line-focus); + } + &:focus:not(:focus-visible) { + outline: 0; + } } - &:focus:not(:focus-visible) { - outline: 0; + @if $mode == 'box-shadow' { + &:focus { + box-shadow: 0 0 0 2px var(--g-color-line-focus); + outline: 0; + } + &:focus:not(:focus-visible) { + box-shadow: none; + } } }