diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9c458..8169aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to the Ippon UI packages are documented in this file, so con The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), with one entry per release listing the affected package versions. +## 2026-08-21 — @ippon-ui/styles 0.1.2 · @ippon-ui/react 0.1.2 + +### Added + +- `combobox` organism: a text field that filters a list of options and lets the reader pick one or several of them. It owns the expanded state, the active option, the selection and the ARIA relations between the field and the list, so a consumer no longer rebuilds the keyboard handling and the ARIA wiring on top of `input-text` and `dropdown`. Keyboard: `ArrowDown` / `ArrowUp` move the active option and open a closed panel and wrap around, which is how the last option is reached without a key of its own, `Enter` selects, `Escape` closes and leaves focus in the field; disabled options are skipped and the active option is scrolled into view. `Home` and `End` are deliberately left alone: the field is a text field, and the combobox pattern gives those two keys to the text cursor rather than to the list. The panel closes on a pointer landing outside the component, and on focus moving to a named element outside it, so `Tab` closes it — unless it lands on something the panel itself holds, such as a footer button, which is what makes that button reachable without a pointer. A focus loss that names no destination closes the panel like any other, unless the element that lost focus also left the document — which is what a browser reports when a footer control removes itself once its work is done; the panel survives that, and a pointer or `Escape` dismisses it afterwards. While the panel is open, `Escape` cancels its own default action, so it dismisses the panel alone rather than also reaching whatever else on the page answers that key; a closed combobox claims nothing. It holds no data: it never fetches, never debounces, never caches and never filters, and renders the options it is handed — the one thing it knows about a search is whether it is still running. +- `IpponSingleCombobox` and `IpponMultiCombobox` React components, generic over the option type. Single and multiple select are two components rather than one discriminated by a boolean, because they do not behave the same: a single select closes on the pick and draws a bare check glyph, a multiple select stays open and carries one counter badge for the whole selection. Both take `query` / `onQueryChange` for the fully controlled query, `options` carrying `{ key, label, description?, disabled? }` alongside whatever else the caller keeps on them — a pick hands that object straight back, so nothing has to be looked up again — a `message` slot for what the list has to say beside its rows, a `footer` slot inside the scrolling area for what it has to offer, `placeholderRows` for the rows still on their way, and `variant`, `disabled` and `readOnly`. `onQueryChange` may hand back a promise: the list then carries `aria-busy` for as long as the newest one is pending, which is the whole of what these components know about asynchronism, and why they take no `busy` prop that could contradict the query. There is no search hook and no search contract to satisfy — the debouncing, the guard that keeps a late answer from overwriting a newer one and the wording belong to the caller and to whatever data layer it already has, and the `Asynchronous` story writes all three out. `IpponMultiCombobox` adds `onSelectionChange`, which reports the next selection for a pick, an unpick and the clear cross alike, and a required `labels` object: the library never invents user-facing wording, since it cannot know the language. The panel opens on focus and on a pointer landing anywhere on the field, so a field that already holds focus still reopens its list after a pick — unless it would hold nothing at all, in which case it stays closed and opens by itself once it has something, since an empty floating box says nothing a reader could use. Both derive `${id}-listbox` and `${id}-option-${key}` internally, so no ARIA relation is wired by hand. A closed field carrying a selection shows that selection whatever is left in the query, so the caller never has to clear the query after a pick. +- `option-list` molecule: stacks `option` rows into a `listbox`, and carries both a footer under them and the content shown when the list has no rows — loading, empty, failed. It scrolls past five rows, so a long list keeps the floating panel a readable size. The footer sits inside the scrolling area and outside the `listbox`, which is what lets a caller put a "load more" button, a "20 of 137" counter or the sentinel of an infinite scroll there. The message is rendered beside the rows and outside the scroll, never in their place: a search still in flight keeps the previous results on screen instead of making the panel flicker at every keystroke. Loading is neither a message nor a progress bar — nothing here knows how far along it is — but `placeholderRows`, a count of `-placeholder` rows drawn at the end of the list. Where they land tells the two kinds of loading apart: what **adds** puts them after the rows already there, where they read as the continuation they are, and what **replaces** shows none at all, since the results on screen are kept until the new ones land — except on the very first load, when there is nothing to keep. The two slots are kept apart on purpose — a control in the `role="status"` region would be announced again at every keystroke, and one that removes itself once its work is done would drop focus with it. +- `option` molecule: one row of a floating list — a check box, a label, an optional secondary text and an optional trailing slot — with `-selected`, `-active`, `-single`, `-disabled` and `-placeholder` alternatives. A `-placeholder` row is a row-shaped hole: shimmer blocks from the same quark _Text_ and _Title_ already use, no `role="option"`, no `id`, no `aria-selected`, and `aria-hidden` — it holds the height of a real row so the list keeps its size, and claims nothing that could be selected or announced. The active row also draws an outline in the system `Highlight` colour under `forced-colors`, where its background highlight would otherwise be thrown away and leave a keyboard reader unable to see what `Enter` would pick. Its check box is drawn, not a real checkbox: a `role="option"` row must hold no focusable control, because focus stays in the field that owns the list — the row cancels the pointer press that would move focus to it, and a disabled row answers no pointer at all, so neither rule is left for a caller to remember. +- `IpponOptionList` and `IpponOption` React components. +- `input-search` atom: a text field with room inside its box for a leading icon and a trailing slot, which `input-text` has not since it is the bare native input. It shares the container of `input-text` through a quark, and adds a read-only state to the alternatives and states `input-text` already documents. +- `IpponInputSearch` React component with `icon` and `suffix` props, forwarding every native input prop. +- `checkbox` atom: a native checkbox and its label, rendered as the box the design asks for, with an `-error` alternative and a focus ring drawn for keyboard focus only. Its box comes from the same quark as the `option` row, so both stay identical. +- `IpponCheckbox` React component. +- `dropdown` organism: `ippon-dropdown---options` ion next to `ippon-dropdown---buttons`, a panel of options that drops the gap and the horizontal padding so an `option-list` fills it edge to edge. Its documentation now states what the triple dash means here: an ion names what the panel ionizes inside it, where an alternative would carry a single dash. Its documentation now covers anchoring a panel to something that is not an invoker button, and the `manual` popover such a panel needs. +- `IpponDropdown` React component: `ion` and `popover` props, both defaulting to the current behaviour (`buttons` and `auto`). `CAP.ts` gains `toIonClass` beside `toAlternativeClass`, so the two CAP shapes are built by name rather than by hand-written template string. +- `ion` atom: `label` option on the Pug mixin and `label` prop on `IpponIon`, setting `aria-label`. A clickable icon carrying no text had no accessible name. +- `IpponLabel` React component: `id` prop, so a label can name a `listbox` through `aria-labelledby`, which a `for` attribute cannot do. + +### Changed + +- `input-text` reads its container — border, radius, background, hover, focus, disabled, `-error` and `-success` — from a quark now shared with `input-search`, instead of declaring it itself. The generated CSS is unchanged byte for byte and the markup is untouched: nothing to do for consumers. + ## 2026-08-11 — @ippon-ui/styles 0.1.1 · @ippon-ui/react 0.1.1 ### Added diff --git a/react/package.json b/react/package.json index f371827..37ad0b9 100644 --- a/react/package.json +++ b/react/package.json @@ -1,7 +1,7 @@ { "name": "@ippon-ui/react", "description": "Ippon UI React Component Library", - "version": "0.1.1", + "version": "0.1.2", "license": "Apache-2.0", "repository": { "type": "git", @@ -39,7 +39,7 @@ }, "peerDependencies": { "@ippon-ui/icons": "~0.0.2", - "@ippon-ui/styles": "~0.1.1", + "@ippon-ui/styles": "~0.1.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/react/src/CAP.ts b/react/src/CAP.ts index e0c996d..704b731 100644 --- a/react/src/CAP.ts +++ b/react/src/CAP.ts @@ -2,6 +2,8 @@ import { Optional } from './Optional'; export const toAlternativeClass = (alternative: string): string => `-${alternative}`; +export const toIonClass = (component: string, ion: string): string => `${component}---${ion}`; + export const optionalToAlternativeClass = (alternative: string | undefined): string | undefined => Optional.ofFalsifiable(alternative).map(toAlternativeClass).orUndefined(); diff --git a/react/src/DataSelectable.ts b/react/src/DataSelectable.ts index 6e14d23..277843d 100644 --- a/react/src/DataSelectable.ts +++ b/react/src/DataSelectable.ts @@ -5,3 +5,8 @@ export type DataSelectable = T & { }; export type DataSelectableWithChildren = DataSelectable>; + +export const toChildSelector = + (child: string) => + (dataSelector?: string): string | undefined => + dataSelector ? `${dataSelector}.${child}` : undefined; diff --git a/react/src/IpponCheckbox.tsx b/react/src/IpponCheckbox.tsx new file mode 100644 index 0000000..4bce1b8 --- /dev/null +++ b/react/src/IpponCheckbox.tsx @@ -0,0 +1,43 @@ +import { clsx } from 'clsx'; +import type { ComponentProps, ReactNode } from 'react'; +import type { DataSelectable } from './DataSelectable.ts'; +import { toChildSelector } from './DataSelectable.ts'; +import { IpponIon } from './IpponIon.tsx'; +import { optionalToAlternativeClass } from './CAP.ts'; + +type IpponCheckboxVanillaProps = { + variant?: 'error'; + children?: ReactNode; +}; + +type IpponCheckboxProps = DataSelectable & IpponCheckboxVanillaProps>; + +const toInputSelector = toChildSelector('input'); + +export const IpponCheckbox = ({ + variant, + children, + dataSelector, + className, + id, + ...inputProps +}: IpponCheckboxProps) => ( + +); diff --git a/react/src/IpponDropdown.tsx b/react/src/IpponDropdown.tsx index 5d3f8da..e905732 100644 --- a/react/src/IpponDropdown.tsx +++ b/react/src/IpponDropdown.tsx @@ -1,9 +1,12 @@ import { clsx } from 'clsx'; import type { KeyboardEventHandler, ToggleEventHandler } from 'react'; +import { toIonClass } from './CAP.ts'; import type { DataSelectableWithChildren } from './DataSelectable.ts'; type IpponDropdownProps = DataSelectableWithChildren<{ id: string; + ion?: 'buttons' | 'options'; + popover?: 'auto' | 'manual'; className?: string; onKeyDown?: KeyboardEventHandler; onToggle?: ToggleEventHandler; @@ -12,8 +15,12 @@ type IpponDropdownProps = DataSelectableWithChildren<{ export const IpponDropdown = (props: IpponDropdownProps) => (
& IpponInputSearchVanillaProps>; + +const toInputSelector = toChildSelector('input'); + +export const IpponInputSearch = ({ + variant, + icon, + suffix, + dataSelector, + className, + type, + ...inputProps +}: IpponInputSearchProps) => ( +
+ + + {suffix === undefined ? null : {suffix}} +
+); diff --git a/react/src/IpponIon.tsx b/react/src/IpponIon.tsx index d217332..978be2b 100644 --- a/react/src/IpponIon.tsx +++ b/react/src/IpponIon.tsx @@ -13,6 +13,7 @@ type IpponIconLogo = { type IpponIconBase = { className?: string; + label?: string; onClick?: () => void; }; @@ -37,6 +38,7 @@ export const IpponIon = (props: IpponIonProps) => { /* NOSONAR */ ; export const IpponLabel = (props: IpponLabelProps) => (