diff --git a/package.json b/package.json index 119d67fe..9f279286 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "bumpp": "^10.1.0", "chalk": "^4.1.2", "commit-and-tag-version": "^12.5.0", + "country-flag-icons": "^1.6.20", "dotenv": "^16.4.7", "eslint": "^9.39.4", "eslint-config-prettier": "^9.1.2", diff --git a/packages/components/src/components/Flag/Flag.mdx b/packages/components/src/components/Flag/Flag.mdx new file mode 100644 index 00000000..bc74015a --- /dev/null +++ b/packages/components/src/components/Flag/Flag.mdx @@ -0,0 +1,128 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './Flag.stories'; + + + +# Flag + + + +Flag shows a small country flag inside a product UI (inline with text, in lists, options, selects). + +It is a thin presentational wrapper: it decorates a flag graphic you provide and does **not** ship +or resolve any flag data. Flag graphics are expected to come from +[`country-flag-icons`](https://www.npmjs.com/package/country-flag-icons) (full ISO 3166-1, redrawn +for small sizes) or any custom source. + +## Import + +```tsx +import { Flag } from '@koobiq/react-components'; +``` + +## Usage + +Provide the flag graphic as `children` — an inline `` is the recommended path (no extra +requests, no base64), but an `` also works. `country-flag-icons` exposes ready-made React flag +components: + +```tsx +import { DE } from 'country-flag-icons/react/3x2'; + + + +; +``` + + + +## Props + + + +## Shape + +Use the `shape` prop: `rectangle` (default), `square`, or `circle`. `square` and `circle` expect a +1:1 source; `circle` additionally clips the graphic to a circle. + + + +## Shadow + +A thin inset hairline separates the flag from the background. It is on by default (`shadow="inset"`) +and adapts to the theme (dark hairline in the light theme, lighter in the dark theme). Turn it off +with `shadow="none"`. + + + +## Sizing + +The flag follows text size — its default height is `1em`. Control it with `font-size`, `width`, or +`height` in CSS. There are no size presets. + + + +## Aspect ratio + +The default box ratio is 3:2. Other ratios (e.g. 4:3) and non-country sources are supported **without +a prop** by overriding the `--kbq-flag-aspect-ratio` CSS variable. + +```tsx +{/* … */} +``` + + + +## Missing flag + +The library ships no flag data, so guard the country code (e.g. with `hasFlag` from +`country-flag-icons`) and render a neutral placeholder with the `empty` prop when a flag is missing — +the layout never breaks and no broken graphic is shown. + + + +## Accessibility + +A flag must never be a silent, meaning-bearing graphic. + +- When the flag carries meaning and has **no adjacent text**, pass a `label`. The host exposes + `role="img"` with the accessible name. +- When adjacent visible text already names the country, mark the flag `decorative`. It is hidden from + assistive technology (`aria-hidden`) to avoid duplication. + + + +## Flag is not a language + +A flag represents a country, not a language. Use a globe icon (not a flag) for language selection. + + + +## Custom look + +Stylized, decorative treatments (rounded corners, drop shadow, "folds" gradient) are your own CSS +plus the exposed variables — not a library prop. Round the corners with `--kbq-flag-border-radius` +and add a shadow/gradient in your own styles. + + + +## Flags in a Select + + + +## CSS variables + +Everything visual that isn't a discrete state is an overridable CSS variable: + +| Variable | Default | Purpose | +| ----------------------------- | --------------------------------------- | ----------------------------------------------- | +| `--kbq-flag-aspect-ratio` | `3 / 2` | Box ratio. `square`/`circle` set it to `1 / 1`. | +| `--kbq-flag-border-radius` | `0` | Corner radius (rounded / stylized look). | +| `--kbq-flag-shadow-color` | `var(--kbq-line-contrast-fade)` | Inset hairline color; theme-adaptive. | +| `--kbq-flag-empty-background` | `var(--kbq-states-background-disabled)` | Placeholder fill. | diff --git a/packages/components/src/components/Flag/Flag.module.css b/packages/components/src/components/Flag/Flag.module.css new file mode 100644 index 00000000..bd3e90f8 --- /dev/null +++ b/packages/components/src/components/Flag/Flag.module.css @@ -0,0 +1,46 @@ +.base { + /* Public, overridable knobs (see the component docs). */ + --kbq-flag-aspect-ratio: 3 / 2; + --kbq-flag-border-radius: 0; + --kbq-flag-shadow-color: var(--kbq-line-contrast-fade); + --kbq-flag-empty-background: var(--kbq-states-background-disabled); + + display: inline-block; + position: relative; + overflow: hidden; + vertical-align: middle; + block-size: 1em; + aspect-ratio: var(--kbq-flag-aspect-ratio); + border-radius: var(--kbq-flag-border-radius); +} + +/* The projected flag graphic fills the box (descendant selector supports wrapped graphics). */ +.base :is(svg, img) { + display: block; + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +/* Inset hairline separating the flag from the background. */ +.base[data-shadow='inset']::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 1px var(--kbq-flag-shadow-color); + pointer-events: none; +} + +.base[data-shape='square'], +.base[data-shape='circle'] { + --kbq-flag-aspect-ratio: 1 / 1; +} + +.base[data-shape='circle'] { + border-radius: 50%; +} + +.base[data-empty] { + background-color: var(--kbq-flag-empty-background); +} diff --git a/packages/components/src/components/Flag/Flag.stories.module.css b/packages/components/src/components/Flag/Flag.stories.module.css new file mode 100644 index 00000000..76e1d18e --- /dev/null +++ b/packages/components/src/components/Flag/Flag.stories.module.css @@ -0,0 +1,21 @@ +/* Gradient imitating folds, blended over the flag (stylized docs example). */ +.stylized::before { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + background: linear-gradient( + 240.64deg, + rgb(255 255 255 / 30%) 0%, + rgb(0 0 0 / 27%) 26.27%, + rgb(255 255 255 / 26%) 37%, + rgb(0 0 0 / 55%) 48.7%, + rgb(0 0 0 / 24%) 59.44%, + rgb(255 255 255 / 30%) 73.64%, + rgb(39 39 39 / 22%) 90.15%, + rgb(0 0 0 / 20%) 100% + ); + mix-blend-mode: overlay; + pointer-events: none; +} diff --git a/packages/components/src/components/Flag/Flag.stories.tsx b/packages/components/src/components/Flag/Flag.stories.tsx new file mode 100644 index 00000000..8d54f844 --- /dev/null +++ b/packages/components/src/components/Flag/Flag.stories.tsx @@ -0,0 +1,294 @@ +import { type CSSProperties } from 'react'; + +import { IconGlobe16 } from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; +import { hasFlag } from 'country-flag-icons'; +import * as flags1x1 from 'country-flag-icons/react/1x1'; +import * as flags3x2 from 'country-flag-icons/react/3x2'; + +import { FlexBox } from '../FlexBox'; +import { SelectNext as Select } from '../SelectNext'; +import { Typography } from '../Typography'; + +import s from './Flag.stories.module.css'; +import { + Flag, + type FlagProps, + flagPropShape, + flagPropShadow, +} from './index.js'; + +const meta = { + title: 'Components/Flag', + component: Flag, + parameters: { + layout: 'centered', + }, + argTypes: { + shape: { + options: flagPropShape, + control: { type: 'inline-radio' }, + }, + shadow: { + options: flagPropShadow, + control: { type: 'inline-radio' }, + }, + }, + tags: ['status:new', 'date:2026-07-10'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Base: Story = { + render: (args) => ( + + + + ), +}; + +export const Shape: Story = { + render: (args) => ( + + {flagPropShape.map((shape) => ( + + + + + {shape} + + ))} + + ), +}; + +export const Shadow: Story = { + render: (args) => ( + + {flagPropShadow.map((shadow) => ( + + + + + shadow = {shadow} + + ))} + + ), +}; + +export const Empty: Story = { + render: (args) => ( + + ), +}; + +/** + * The library ships no flag data, so guard the code with `hasFlag` and render a + * neutral placeholder (or your own glyph) when a flag is missing. + */ +export const Fallback: Story = { + render: (args) => ( + + {['DE', 'ZZ'].map((code) => { + const known = hasFlag(code); + const FlagIcon = flags3x2[code as keyof typeof flags3x2]; + + return ( + + + {known ? : null} + + {code} + + ); + })} + + ), +}; + +/** + * When the flag carries meaning and has no adjacent text, pass a `label` + * (`role="img"`). When adjacent text already names the country, mark the flag + * `decorative` so it is hidden from assistive tech. + */ +export const Accessibility: Story = { + render: (args) => ( + + + + + + + + {' '} + Germany + + + ), +}; + +/** + * The flag follows text size — control it with `font-size` / `width` / `height`. + */ +export const Sizes: Story = { + render: (args) => ( + + {[16, 24, 32, 48].map((size) => ( + + + + ))} + + ), +}; + +/** + * Non-country / other ratios are supported without a prop by overriding + * `--kbq-flag-aspect-ratio`. + */ +export const AspectRatio: Story = { + render: (args) => ( + + + + ), +}; + +/** + * A stylized, volumetric look is consumer CSS — rounded corners via + * `--kbq-flag-border-radius`, plus a drop shadow and a "folds" gradient overlay. + */ +export const Custom: Story = { + render: (args) => ( + + + + ), +}; + +// Languages are picked with a neutral globe icon, not a flag: +// one language spans several countries. +const languages = [ + 'English (UK)', + 'English (US)', + 'Español', + 'Français', + 'Русский', + 'हिन्दी', + '中文', +]; + +/** + * A flag represents a country, not a language. Use a globe icon (not a flag) for + * language selection. + */ +export const NotForLanguage: Story = { + render: () => ( + + ), +}; + +const countries = [ + { code: 'DE', name: 'Germany' }, + { code: 'FR', name: 'France' }, + { code: 'JP', name: 'Japan' }, + { code: 'US', name: 'United States' }, + { code: 'BR', name: 'Brazil' }, +]; + +const CountryOption = ({ code, name }: { code: string; name: string }) => { + const FlagIcon = flags3x2[code as keyof typeof flags3x2]; + + return ( + + + + + {name} + + ); +}; + +export const FlagsInSelect: Story = { + render: () => ( + + ), +}; diff --git a/packages/components/src/components/Flag/Flag.test.tsx b/packages/components/src/components/Flag/Flag.test.tsx new file mode 100644 index 00000000..fd8ab759 --- /dev/null +++ b/packages/components/src/components/Flag/Flag.test.tsx @@ -0,0 +1,111 @@ +import { createRef } from 'react'; + +import { screen, render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { Flag, flagPropShape } from './index.js'; + +describe('Flag', () => { + const baseProps = { 'data-testid': 'flag' }; + + const getRoot = () => screen.getByTestId('flag'); + + it('should receive ref', () => { + const ref = createRef(); + const { container } = render(); + const flag = container.querySelector('span'); + expect(ref.current).toBe(flag); + }); + + it('should render the component as a div with the correct tag', () => { + const ref = createRef(); + + render(); + + expect(getRoot().tagName).toBe('DIV'); + }); + + it('should project the flag graphic', () => { + render( + + + + ); + + expect(screen.getByTestId('graphic')).toBeInTheDocument(); + }); + + describe('check the shape prop', () => { + it('should default to the rectangle shape', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-shape', 'rectangle'); + }); + + it.each(flagPropShape)('should apply the shape as a "%s"', (shape) => { + render(); + + expect(getRoot()).toHaveAttribute('data-shape', shape); + }); + }); + + describe('check the shadow prop', () => { + it('should default to the inset shadow', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-shadow', 'inset'); + }); + + it('should apply the shadow as a "none"', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-shadow', 'none'); + }); + }); + + describe('check the empty prop', () => { + it('should not set data-empty by default', () => { + render(); + + expect(getRoot()).not.toHaveAttribute('data-empty'); + }); + + it('should set data-empty when empty is true', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-empty', 'true'); + }); + }); + + describe('check the accessibility contract', () => { + it('should be labelled with role="img" when the label is provided', () => { + render(); + + const flag = getRoot(); + + expect(flag).toHaveAttribute('role', 'img'); + expect(flag).toHaveAttribute('aria-label', 'Germany'); + expect(flag).not.toHaveAttribute('aria-hidden'); + }); + + it('should be hidden from assistive tech when decorative', () => { + render(); + + const flag = getRoot(); + + expect(flag).toHaveAttribute('aria-hidden', 'true'); + expect(flag).not.toHaveAttribute('role'); + expect(flag).not.toHaveAttribute('aria-label'); + }); + + it('should be neutral with neither label nor decorative', () => { + render(); + + const flag = getRoot(); + + expect(flag).not.toHaveAttribute('role'); + expect(flag).not.toHaveAttribute('aria-label'); + expect(flag).not.toHaveAttribute('aria-hidden'); + }); + }); +}); diff --git a/packages/components/src/components/Flag/Flag.tsx b/packages/components/src/components/Flag/Flag.tsx new file mode 100644 index 00000000..d68f355d --- /dev/null +++ b/packages/components/src/components/Flag/Flag.tsx @@ -0,0 +1,56 @@ +'use client'; + +import type { AriaAttributes, ComponentPropsWithRef, ElementType } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import s from './Flag.module.css'; +import type { FlagBaseProps } from './index'; + +/** + * Flag is a thin presentational wrapper that decorates a country flag graphic + * provided by the consumer (inline `svg` or `img`). It holds no flag data. + */ +export const Flag = polymorphicForwardRef<'span', FlagBaseProps>( + (props, ref) => { + const { + as: Tag = 'span', + shape = 'rectangle', + shadow = 'inset', + empty = false, + decorative = false, + label, + className, + children, + ...other + } = props; + + // Accessibility contract: + // - decorative → hidden from assistive tech, no role/label. + // - label provided → labelled mode (`role="img"` + accessible name). + // - Neither → neutral, the projected graphic / surrounding text carries meaning. + const a11yProps: AriaAttributes & { role?: string } = decorative + ? { 'aria-hidden': true } + : label + ? { role: 'img', 'aria-label': label } + : {}; + + return ( + + {children} + + ); + } +); + +export type FlagProps = ComponentPropsWithRef< + typeof Flag +>; diff --git a/packages/components/src/components/Flag/index.ts b/packages/components/src/components/Flag/index.ts new file mode 100644 index 00000000..72d69f0b --- /dev/null +++ b/packages/components/src/components/Flag/index.ts @@ -0,0 +1,2 @@ +export * from './Flag'; +export * from './types'; diff --git a/packages/components/src/components/Flag/types.ts b/packages/components/src/components/Flag/types.ts new file mode 100644 index 00000000..b358d30e --- /dev/null +++ b/packages/components/src/components/Flag/types.ts @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; + +export const flagPropShape = ['rectangle', 'square', 'circle'] as const; + +export type FlagPropShape = (typeof flagPropShape)[number]; + +export const flagPropShadow = ['inset', 'none'] as const; + +export type FlagPropShadow = (typeof flagPropShadow)[number]; + +export type FlagBaseProps = { + /** + * The shape of the flag. + * `square` and `circle` expect a 1:1 source; `circle` additionally clips to a circle. + */ + shape?: FlagPropShape; + /** The inset hairline that separates the flag from the background. */ + shadow?: FlagPropShadow; + /** Renders a neutral placeholder (unknown or invalid country). */ + empty?: boolean; + /** Hides the flag from assistive technology (use when adjacent text already names it). */ + decorative?: boolean; + /** The accessible name (use when the flag has meaning and no adjacent text). */ + label?: string; + /** The projected flag graphic (inline `svg` or `img`). */ + children?: ReactNode; + /** Additional CSS-classes. */ + className?: string; +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 9cb9d87c..b02814d8 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -54,6 +54,7 @@ export * from './ActionsPanel'; export * from './Tree'; export * from './TreeSelect'; export * from './EmptyState'; +export * from './Flag'; export * from './layout'; export { useListData, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21abf963..a0dea05c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: commit-and-tag-version: specifier: ^12.5.0 version: 12.5.0 + country-flag-icons: + specifier: ^1.6.20 + version: 1.6.20 dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -4196,10 +4199,12 @@ packages: conventional-changelog-atom@3.0.0: resolution: {integrity: sha512-pnN5bWpH+iTUWU3FaYdw5lJmfWeqSyrUkG+wyHBI9tC1dLNnHkbAOg1SzTQ7zBqiFrfo55h40VsGXWMdopwc5g==} engines: {node: '>=14'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-codemirror@3.0.0: resolution: {integrity: sha512-wzchZt9HEaAZrenZAUUHMCFcuYzGoZ1wG/kTRMICxsnW5AXohYMRxnyecP9ob42Gvn5TilhC0q66AtTPRSNMfw==} engines: {node: '>=14'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-config-spec@2.1.0: resolution: {integrity: sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ==} @@ -4215,26 +4220,32 @@ packages: conventional-changelog-core@5.0.2: resolution: {integrity: sha512-RhQOcDweXNWvlRwUDCpaqXzbZemKPKncCWZG50Alth72WITVd6nhVk9MJ6w1k9PFNBcZ3YwkdkChE+8+ZwtUug==} engines: {node: '>=14'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-ember@3.0.0: resolution: {integrity: sha512-7PYthCoSxIS98vWhVcSphMYM322OxptpKAuHYdVspryI0ooLDehRXWeRWgN+zWSBXKl/pwdgAg8IpLNSM1/61A==} engines: {node: '>=14'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-eslint@4.0.0: resolution: {integrity: sha512-nEZ9byP89hIU0dMx37JXQkE1IpMmqKtsaR24X7aM3L6Yy/uAtbb+ogqthuNYJkeO1HyvK7JsX84z8649hvp43Q==} engines: {node: '>=14'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-express@3.0.0: resolution: {integrity: sha512-HqxihpUMfIuxvlPvC6HltA4ZktQEUan/v3XQ77+/zbu8No/fqK3rxSZaYeHYant7zRxQNIIli7S+qLS9tX9zQA==} engines: {node: '>=14'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jquery@4.0.0: resolution: {integrity: sha512-TTIN5CyzRMf8PUwyy4IOLmLV2DFmPtasKN+x7EQKzwSX8086XYwo+NeaeA3VUT8bvKaIy5z/JoWUvi7huUOgaw==} engines: {node: '>=14'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jshint@3.0.0: resolution: {integrity: sha512-bQof4byF4q+n+dwFRkJ/jGf9dCNUv4/kCDcjeCizBvfF81TeimPZBB6fT4HYbXgxxfxWXNl/i+J6T0nI4by6DA==} engines: {node: '>=14'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-preset-loader@3.0.0: resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==} @@ -4306,6 +4317,9 @@ packages: typescript: optional: true + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -5283,13 +5297,13 @@ packages: git-raw-commits@3.0.0: resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -5299,7 +5313,7 @@ packages: git-semver-tags@5.0.1: resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true gitconfiglocal@1.0.0: @@ -13228,6 +13242,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + country-flag-icons@1.6.20: {} + crc-32@1.2.2: {} crc32-stream@6.0.0: