diff --git a/package.json b/package.json index 797156a0..e489bf73 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..4d7a4220 --- /dev/null +++ b/packages/components/src/components/Flag/Flag.mdx @@ -0,0 +1,130 @@ +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`. The recommended path is a ready-made React flag component +from [`country-flag-icons`](https://www.npmjs.com/package/country-flag-icons) — no extra requests, no +base64, and it renders an inline `` under the hood. A raw inline `` or an `` also +works. + +```tsx +import { DE } from 'country-flag-icons/react/3x2'; + + + +; +``` + + + +## Props + + + +## Shape + +The `shape` prop controls the corner treatment: `rectangle` (default) or `circle`. `circle` clips the +flag to a circle and defaults its ratio to `1 / 1`. For a sharp-cornered square, use +`aspectRatio="1 / 1"` (see below). + + + +## Shadow + +A thin inset hairline separates the flag from the background. It is on by default and adapts to the theme (dark hairline in the light theme, lighter in the dark theme). + + + +## Sizing + +By default the flag's height is `1em`, so it tracks the surrounding text — inline, in lists, and in +options you set nothing. For an explicit height use the `size` prop (a number is pixels, a string is +any CSS length), or override the `--kbq-flag-size` variable to size a whole context at once. + +```tsx + // 24px + // any CSS length +``` + + + +## Aspect ratio + +Set the box ratio with the `aspectRatio` prop. + + + +## 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 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. Flag adds no ARIA semantics on its own — +supply them with standard attributes: + +- When the flag carries meaning and has **no adjacent text**, pass a `aria-label` and `role="img"`. +- When adjacent visible text already names the country, mark the flag with `aria-hidden="true"`. It is hidden from + assistive technology 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-size` | `1em` | Flag height (also settable via the `size` prop). | +| `--kbq-flag-aspect-ratio` | `3 / 2` | Box ratio (also settable via the `aspectRatio` prop). | +| `--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..5663403e --- /dev/null +++ b/packages/components/src/components/Flag/Flag.module.css @@ -0,0 +1,47 @@ +.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: var(--kbq-flag-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 (shown by default). */ +.base::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid var(--kbq-flag-shadow-color); + pointer-events: none; +} + +.base[data-hide-shadow]::after { + content: none; +} + +/* `circle` only clips the corners; its 1:1 ratio comes from the component. */ +.base[data-shape='circle'] { + border-radius: 50%; +} + +/* No projected graphic → neutral placeholder (e.g. unknown/invalid country). */ +.base: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..35276ac2 --- /dev/null +++ b/packages/components/src/components/Flag/Flag.stories.tsx @@ -0,0 +1,322 @@ +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 { DE as DE1x1 } from 'country-flag-icons/react/1x1'; +import { BR, DE, FR, JP, US } 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, + flagPropAspectRatio, +} from './index.js'; + +// These small maps let examples look up a flag by ISO code. +const flags3x2 = { BR, DE, FR, JP, US }; +const flags1x1 = { DE: DE1x1 }; + +const meta = { + title: 'Components/Flag', + component: Flag, + parameters: { + layout: 'centered', + }, + argTypes: { + shape: { + options: flagPropShape, + control: { type: 'inline-radio' }, + }, + aspectRatio: { + options: flagPropAspectRatio, + control: { type: 'select' }, + }, + }, + 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} + + ))} + + + + + square + + + ), +}; + +export const Shadow: Story = { + render: (args) => ( + + {[false, true].map((hideShadow) => ( + + + + + + hideShadow = {String(hideShadow)} + + + ))} + + ), +}; + +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} + + ); + })} + + ), +}; + +/** + * Flag adds no ARIA semantics on its own — supply them with standard attributes: + * `role="img"` + `aria-label` for a meaningful flag with no adjacent text, + * `aria-labelledby` when visible text names it, or `aria-hidden` when it is + * purely decorative. + */ +export const Accessibility: Story = { + render: (args) => ( + + {/* Meaningful flag, no adjacent text. */} + + + + + {/* Decorative flag beside visible text. */} + + + Germany + + + ), +}; + +/** + * Set an explicit height with the `size` prop (a number is pixels, a string is + * any CSS length). Omit it and the flag defaults to `1em`, tracking the text. + */ +export const Sizes: Story = { + render: (args) => ( + + {[16, 24, 32, 48].map((size) => ( + + + + ))} + + ), +}; + +/** + * Set the box ratio with the `aspectRatio` prop — common presets plus any CSS + * value. The graphic is cropped to fill (`object-fit: cover`). + */ +export const AspectRatio: Story = { + render: (args) => ( + + + + + + + + + + 4 / 3 + + + ), +}; + +/** + * 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..67e3d3c7 --- /dev/null +++ b/packages/components/src/components/Flag/Flag.test.tsx @@ -0,0 +1,155 @@ +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 hideShadow prop', () => { + it('should show the shadow by default', () => { + render(); + + expect(getRoot()).not.toHaveAttribute('data-hide-shadow'); + }); + + it('should set data-hide-shadow when hideShadow is true', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-hide-shadow', 'true'); + }); + }); + + describe('check the size prop', () => { + it('should not set --kbq-flag-size by default', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-flag-size')).toBe(''); + }); + + it('should set --kbq-flag-size in pixels for a number', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-flag-size')).toBe('24px'); + }); + + it('should set --kbq-flag-size verbatim for a string', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-flag-size')).toBe('2rem'); + }); + + it('should merge with the consumer style', () => { + render(); + + const flag = getRoot(); + + expect(flag.style.getPropertyValue('--kbq-flag-size')).toBe('24px'); + expect(flag.style.color).toBe('red'); + }); + }); + + describe('check the aspectRatio prop', () => { + it('should not set --kbq-flag-aspect-ratio by default', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-flag-aspect-ratio')).toBe( + '' + ); + }); + + it('should set --kbq-flag-aspect-ratio from a string', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-flag-aspect-ratio')).toBe( + '4 / 3' + ); + }); + + it('should default the ratio to 1 / 1 for a circle', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-flag-aspect-ratio')).toBe( + '1 / 1' + ); + }); + + it('should let an explicit aspectRatio override the circle default', () => { + render(); + + expect(getRoot().style.getPropertyValue('--kbq-flag-aspect-ratio')).toBe( + '4 / 3' + ); + }); + }); + + describe('accessibility attributes', () => { + it('should forward role and aria-label for a meaningful flag', () => { + render(); + + const flag = getRoot(); + + expect(flag).toHaveAttribute('role', 'img'); + expect(flag).toHaveAttribute('aria-label', 'Germany'); + }); + + it('should forward aria-hidden for a decorative flag', () => { + render(