From c2fecc894247e89770ea205772360afd5f0d9d28 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 10 Jul 2026 16:17:22 +0300 Subject: [PATCH 1/6] feat(Flag): add Flag component (#DS-1405) --- package.json | 1 + .../components/src/components/Flag/Flag.mdx | 128 ++++++++ .../src/components/Flag/Flag.module.css | 46 +++ .../components/Flag/Flag.stories.module.css | 21 ++ .../src/components/Flag/Flag.stories.tsx | 294 ++++++++++++++++++ .../src/components/Flag/Flag.test.tsx | 111 +++++++ .../components/src/components/Flag/Flag.tsx | 56 ++++ .../components/src/components/Flag/index.ts | 2 + .../components/src/components/Flag/types.ts | 29 ++ packages/components/src/components/index.ts | 1 + pnpm-lock.yaml | 22 +- 11 files changed, 708 insertions(+), 3 deletions(-) create mode 100644 packages/components/src/components/Flag/Flag.mdx create mode 100644 packages/components/src/components/Flag/Flag.module.css create mode 100644 packages/components/src/components/Flag/Flag.stories.module.css create mode 100644 packages/components/src/components/Flag/Flag.stories.tsx create mode 100644 packages/components/src/components/Flag/Flag.test.tsx create mode 100644 packages/components/src/components/Flag/Flag.tsx create mode 100644 packages/components/src/components/Flag/index.ts create mode 100644 packages/components/src/components/Flag/types.ts 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..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: From ef0dc124de2674695114ed7dbd9230b344ce3b7b Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 13 Jul 2026 16:38:42 +0300 Subject: [PATCH 2/6] chore: after review --- .../components/src/components/Flag/Flag.mdx | 43 ++++--- .../src/components/Flag/Flag.module.css | 22 +++- .../src/components/Flag/Flag.stories.tsx | 108 ++++++++---------- .../src/components/Flag/Flag.test.tsx | 58 ++++++---- .../components/src/components/Flag/Flag.tsx | 36 +++--- .../components/src/components/Flag/types.ts | 24 ++-- 6 files changed, 152 insertions(+), 139 deletions(-) diff --git a/packages/components/src/components/Flag/Flag.mdx b/packages/components/src/components/Flag/Flag.mdx index bc74015a..b47870d1 100644 --- a/packages/components/src/components/Flag/Flag.mdx +++ b/packages/components/src/components/Flag/Flag.mdx @@ -35,7 +35,7 @@ components: ```tsx import { DE } from 'country-flag-icons/react/3x2'; - + ; ``` @@ -55,16 +55,20 @@ Use the `shape` prop: `rectangle` (default), `square`, or `circle`. `square` and ## 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"`. +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 -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. +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 +``` @@ -82,19 +86,19 @@ a prop** by overriding the `--kbq-flag-aspect-ratio` CSS variable. ## 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. +`country-flag-icons`) and render a neutral placeholder when a flag is missing — +the layout never breaks and no broken graphic is shown.the layout never breaks and no broken graphic is shown. ## Accessibility -A flag must never be a silent, meaning-bearing graphic. +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 `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. +- 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. @@ -120,9 +124,10 @@ and add a shadow/gradient in your own styles. 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. | +| Variable | Default | Purpose | +| ----------------------------- | --------------------------------------- | ------------------------------------------------ | +| `--kbq-flag-size` | `1em` | Flag height (also settable via the `size` prop). | +| `--kbq-flag-aspect-ratio` | `3 / 2` | Box ratio (`square`/`circle` force `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 index bd3e90f8..6d5e50e0 100644 --- a/packages/components/src/components/Flag/Flag.module.css +++ b/packages/components/src/components/Flag/Flag.module.css @@ -9,7 +9,7 @@ position: relative; overflow: hidden; vertical-align: middle; - block-size: 1em; + block-size: var(--kbq-flag-size, 1em); aspect-ratio: var(--kbq-flag-aspect-ratio); border-radius: var(--kbq-flag-border-radius); } @@ -22,25 +22,35 @@ object-fit: cover; } -/* Inset hairline separating the flag from the background. */ -.base[data-shadow='inset']::after { +/* Inset hairline separating the flag from the background (shown by default). */ +.base::after { content: ''; position: absolute; inset: 0; border-radius: inherit; - box-shadow: inset 0 0 0 1px var(--kbq-flag-shadow-color); + border: 1px solid var(--kbq-flag-shadow-color); pointer-events: none; } +.base[data-hide-shadow]::after { + content: none; +} + +/* + * `square`/`circle` require a 1:1 box. Set `aspect-ratio` directly (not via + * `--kbq-flag-aspect-ratio`) so a consumer overriding that variable can't + * accidentally distort the circle. + */ .base[data-shape='square'], .base[data-shape='circle'] { - --kbq-flag-aspect-ratio: 1 / 1; + aspect-ratio: 1 / 1; } .base[data-shape='circle'] { border-radius: 50%; } -.base[data-empty] { +/* 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.tsx b/packages/components/src/components/Flag/Flag.stories.tsx index 8d54f844..d0e3d9f5 100644 --- a/packages/components/src/components/Flag/Flag.stories.tsx +++ b/packages/components/src/components/Flag/Flag.stories.tsx @@ -3,20 +3,19 @@ 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 { DE as DE1x1 } from 'country-flag-icons/react/1x1'; +import { BR, DE, FR, JP, NP, 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, - flagPropShadow, -} from './index.js'; +import { Flag, type FlagProps, flagPropShape } from './index.js'; + +// These small maps let examples look up a flag by ISO code. +const flags3x2 = { BR, DE, FR, JP, NP, US }; +const flags1x1 = { DE: DE1x1 }; const meta = { title: 'Components/Flag', @@ -29,10 +28,6 @@ const meta = { options: flagPropShape, control: { type: 'inline-radio' }, }, - shadow: { - options: flagPropShadow, - control: { type: 'inline-radio' }, - }, }, tags: ['status:new', 'date:2026-07-10'], } satisfies Meta; @@ -53,11 +48,7 @@ export const Shape: Story = { {flagPropShape.map((shape) => ( - + {shape} @@ -70,16 +61,19 @@ export const Shape: Story = { export const Shadow: Story = { render: (args) => ( - {flagPropShadow.map((shadow) => ( - - + {[false, true].map((hideShadow) => ( + + - shadow = {shadow} + + hideShadow = {String(hideShadow)} + ))} @@ -88,12 +82,7 @@ export const Shadow: Story = { export const Empty: Story = { render: (args) => ( - + ), }; @@ -112,9 +101,9 @@ export const Fallback: Story = { {known ? : null} @@ -127,39 +116,39 @@ export const Fallback: Story = { }; /** - * 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. + * 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 - + + Germany + ), }; /** - * The flag follows text size — control it with `font-size` / `width` / `height`. + * 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) => ( - + ))} @@ -175,10 +164,10 @@ export const AspectRatio: Story = { render: (args) => ( @@ -193,12 +182,13 @@ export const Custom: Story = { render: (args) => ( { return ( - + {name} diff --git a/packages/components/src/components/Flag/Flag.test.tsx b/packages/components/src/components/Flag/Flag.test.tsx index fd8ab759..635c4edc 100644 --- a/packages/components/src/components/Flag/Flag.test.tsx +++ b/packages/components/src/components/Flag/Flag.test.tsx @@ -49,56 +49,66 @@ describe('Flag', () => { }); }); - describe('check the shadow prop', () => { - it('should default to the inset shadow', () => { + describe('check the hideShadow prop', () => { + it('should show the shadow by default', () => { render(); - expect(getRoot()).toHaveAttribute('data-shadow', 'inset'); + expect(getRoot()).not.toHaveAttribute('data-hide-shadow'); }); - it('should apply the shadow as a "none"', () => { - render(); + it('should set data-hide-shadow when hideShadow is true', () => { + render(); - expect(getRoot()).toHaveAttribute('data-shadow', 'none'); + expect(getRoot()).toHaveAttribute('data-hide-shadow', 'true'); }); }); - describe('check the empty prop', () => { - it('should not set data-empty by default', () => { + describe('check the size prop', () => { + it('should not set --kbq-flag-size by default', () => { render(); - expect(getRoot()).not.toHaveAttribute('data-empty'); + expect(getRoot().style.getPropertyValue('--kbq-flag-size')).toBe(''); }); - it('should set data-empty when empty is true', () => { - render(); + it('should set --kbq-flag-size in pixels for a number', () => { + render(); - expect(getRoot()).toHaveAttribute('data-empty', 'true'); + 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 accessibility contract', () => { - it('should be labelled with role="img" when the label is provided', () => { - render(); + 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'); - expect(flag).not.toHaveAttribute('aria-hidden'); }); - it('should be hidden from assistive tech when decorative', () => { - render(); - - const flag = getRoot(); + it('should forward aria-hidden for a decorative flag', () => { + render( ))} + + + + + square + ), }; @@ -157,20 +172,43 @@ export const Sizes: Story = { }; /** - * Non-country / other ratios are supported without a prop by overriding - * `--kbq-flag-aspect-ratio`. + * 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 + + ), }; diff --git a/packages/components/src/components/Flag/Flag.test.tsx b/packages/components/src/components/Flag/Flag.test.tsx index 635c4edc..67e3d3c7 100644 --- a/packages/components/src/components/Flag/Flag.test.tsx +++ b/packages/components/src/components/Flag/Flag.test.tsx @@ -92,6 +92,40 @@ describe('Flag', () => { }); }); + 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(); diff --git a/packages/components/src/components/Flag/Flag.tsx b/packages/components/src/components/Flag/Flag.tsx index 9637e055..02c3fa79 100644 --- a/packages/components/src/components/Flag/Flag.tsx +++ b/packages/components/src/components/Flag/Flag.tsx @@ -2,7 +2,7 @@ import type { ComponentPropsWithRef, CSSProperties, ElementType } from 'react'; -import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; +import { clsx, isNumber, polymorphicForwardRef } from '@koobiq/react-core'; import s from './Flag.module.css'; import type { FlagBaseProps } from './index'; @@ -22,26 +22,28 @@ export const Flag = polymorphicForwardRef<'span', FlagBaseProps>( shape = 'rectangle', hideShadow = false, size, + // `circle` needs a 1:1 box; an explicit `aspectRatio` still wins. + aspectRatio = shape === 'circle' ? '1 / 1' : undefined, style, className, children, ...other } = props; - const sizeStyle = - size !== undefined - ? ({ - ...style, - '--kbq-flag-size': typeof size === 'number' ? `${size}px` : size, - } as CSSProperties) - : style; + const flagStyle = { + ...style, + ...(size !== undefined && { + '--kbq-flag-size': isNumber(size) ? `${size}px` : size, + }), + ...(aspectRatio && { '--kbq-flag-aspect-ratio': aspectRatio }), + } as CSSProperties; return ( diff --git a/packages/components/src/components/Flag/types.ts b/packages/components/src/components/Flag/types.ts index 534ba0c2..481773ef 100644 --- a/packages/components/src/components/Flag/types.ts +++ b/packages/components/src/components/Flag/types.ts @@ -1,13 +1,17 @@ import type { CSSProperties, ReactNode } from 'react'; -export const flagPropShape = ['rectangle', 'square', 'circle'] as const; +export const flagPropShape = ['rectangle', 'circle'] as const; export type FlagPropShape = (typeof flagPropShape)[number]; +export const flagPropAspectRatio = ['3 / 2', '1 / 1'] as const; + +export type FlagPropAspectRatio = (typeof flagPropAspectRatio)[number]; + export type FlagBaseProps = { /** - * The shape of the flag. - * `square` and `circle` expect a 1:1 source; `circle` additionally clips to a circle. + * The corner treatment of the flag. `circle` clips it to a circle (its + * aspect ratio defaults to `1 / 1`). */ shape?: FlagPropShape; /** Hides the inset hairline that separates the flag from the background. */ @@ -18,6 +22,12 @@ export type FlagBaseProps = { * tracks the surrounding text. */ size?: number | string; + /** + * The box aspect ratio. Common presets are offered, but any CSS + * `aspect-ratio` value works (e.g. `4 / 3`). + * @default '3 / 2' (`'1 / 1'` when `shape="circle"`) + */ + aspectRatio?: FlagPropAspectRatio | (string & {}); /** The projected flag graphic (inline `svg` or `img`). */ children?: ReactNode; /** Additional CSS-classes. */ From 595fbea79288098aa93cc589b284960d2df4e448 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 13 Jul 2026 17:41:59 +0300 Subject: [PATCH 4/6] chore: upd documentation --- packages/components/src/components/Flag/Flag.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/Flag/Flag.mdx b/packages/components/src/components/Flag/Flag.mdx index 77f6c6c0..4d7a4220 100644 --- a/packages/components/src/components/Flag/Flag.mdx +++ b/packages/components/src/components/Flag/Flag.mdx @@ -28,9 +28,10 @@ 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: +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'; From da6e54490d73bcc66df0b8420eface5e4ebf8248 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 14 Jul 2026 17:09:50 +0300 Subject: [PATCH 5/6] chore: after review --- .../components/src/components/Flag/types.ts | 8 +++- tools/api-extractor/config.json | 1 + tools/public_api_guard/components/Flag.api.md | 44 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tools/public_api_guard/components/Flag.api.md diff --git a/packages/components/src/components/Flag/types.ts b/packages/components/src/components/Flag/types.ts index 481773ef..0ef4bd1b 100644 --- a/packages/components/src/components/Flag/types.ts +++ b/packages/components/src/components/Flag/types.ts @@ -12,9 +12,13 @@ export type FlagBaseProps = { /** * The corner treatment of the flag. `circle` clips it to a circle (its * aspect ratio defaults to `1 / 1`). + * @default 'rectangle' */ shape?: FlagPropShape; - /** Hides the inset hairline that separates the flag from the background. */ + /** + * Hides the inset hairline that separates the flag from the background. + * @default false + */ hideShadow?: boolean; /** * The size of the flag (its height). A `number` is treated as pixels, a @@ -27,7 +31,7 @@ export type FlagBaseProps = { * `aspect-ratio` value works (e.g. `4 / 3`). * @default '3 / 2' (`'1 / 1'` when `shape="circle"`) */ - aspectRatio?: FlagPropAspectRatio | (string & {}); + aspectRatio?: FlagPropAspectRatio | CSSProperties['aspectRatio']; /** The projected flag graphic (inline `svg` or `img`). */ children?: ReactNode; /** Additional CSS-classes. */ diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 202e10d5..4faa8ef5 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -18,6 +18,7 @@ "DatePicker", "Divider", "EmptyState", + "Flag", "FlexBox", "Form", "FormField", diff --git a/tools/public_api_guard/components/Flag.api.md b/tools/public_api_guard/components/Flag.api.md new file mode 100644 index 00000000..1f8c7e82 --- /dev/null +++ b/tools/public_api_guard/components/Flag.api.md @@ -0,0 +1,44 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ComponentPropsWithRef } from 'react'; +import type { CSSProperties } from 'react'; +import type { ElementType } from 'react'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import type { ReactNode } from 'react'; + +// @public +export const Flag: PolyForwardComponent<"span", FlagBaseProps, ElementType>; + +// @public (undocumented) +export type FlagBaseProps = { + shape?: FlagPropShape; + hideShadow?: boolean; + size?: number | string; + aspectRatio?: FlagPropAspectRatio | (string & {}); + children?: ReactNode; + className?: string; + style?: CSSProperties; +}; + +// @public (undocumented) +export type FlagPropAspectRatio = (typeof flagPropAspectRatio)[number]; + +// @public (undocumented) +export const flagPropAspectRatio: readonly ["3 / 2", "1 / 1"]; + +// @public (undocumented) +export type FlagProps = ComponentPropsWithRef>; + +// @public (undocumented) +export type FlagPropShape = (typeof flagPropShape)[number]; + +// @public (undocumented) +export const flagPropShape: readonly ["rectangle", "circle"]; + +// (No @packageDocumentation comment for this package) + +``` From 08df406bcb7abc316e93dec1d45fab1427bb5c31 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 14 Jul 2026 17:58:05 +0300 Subject: [PATCH 6/6] chore: update golden file --- tools/public_api_guard/components/Flag.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/public_api_guard/components/Flag.api.md b/tools/public_api_guard/components/Flag.api.md index 1f8c7e82..91193afb 100644 --- a/tools/public_api_guard/components/Flag.api.md +++ b/tools/public_api_guard/components/Flag.api.md @@ -18,7 +18,7 @@ export type FlagBaseProps = { shape?: FlagPropShape; hideShadow?: boolean; size?: number | string; - aspectRatio?: FlagPropAspectRatio | (string & {}); + aspectRatio?: FlagPropAspectRatio | CSSProperties['aspectRatio']; children?: ReactNode; className?: string; style?: CSSProperties;