From dc463e5dc613d2ec2499b0d46488a5a6415109be Mon Sep 17 00:00:00 2001 From: Anthony Rey Date: Tue, 18 Aug 2026 12:18:36 +0200 Subject: [PATCH] feat(styles,react): add combobox, option list, input search and checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library had a floating panel of actions but no floating panel of options, so every consumer needing a picker rebuilt one on top of input-text and dropdown, inventing its own keyboard handling and its own ARIA wiring. dropdown could not be reused as is: it is opened by an invoker button carrying command/commandfor, which anchors the panel implicitly, while a combobox is anchored to a text field that is not an invoker and has to open programmatically. The panel is therefore a manual popover — with auto, the click landing in the field would light-dismiss the panel that click just opened — anchored explicitly through anchor-name and position-anchor, with anchor-scope confining the name to each instance. Two quarks keep the new atoms from drifting: one holds the field container input-text and input-search now share, the other the check box the checkbox atom and the option row share. The input-text refactor leaves the generated CSS unchanged byte for byte. Closes #51 --- CHANGELOG.md | 23 + react/package.json | 4 +- react/src/Combobox.ts | 21 + react/src/ComboboxActiveOption.ts | 69 +++ react/src/ComboboxDisclosure.ts | 78 +++ react/src/ComboboxField.ts | 86 ++++ react/src/ComboboxOption.ts | 32 ++ react/src/ComboboxOptions.tsx | 40 ++ react/src/ComboboxSearch.ts | 138 ++++++ react/src/DataSelectable.ts | 5 + react/src/IpponCheckbox.tsx | 43 ++ react/src/IpponDropdown.tsx | 10 +- react/src/IpponInputSearch.tsx | 42 ++ react/src/IpponIon.tsx | 2 + react/src/IpponLabel.tsx | 2 + react/src/IpponMultiCombobox.tsx | 151 ++++++ react/src/IpponOption.tsx | 53 ++ react/src/IpponOptionList.tsx | 55 +++ react/src/IpponSingleCombobox.tsx | 120 +++++ react/src/index.ts | 15 +- react/stories/IpponCheckbox.stories.tsx | 35 ++ react/stories/IpponInputSearch.stories.tsx | 57 +++ react/stories/IpponMultiCombobox.stories.tsx | 126 +++++ react/stories/IpponOption.stories.tsx | 50 ++ react/stories/IpponOptionList.stories.tsx | 100 ++++ react/stories/IpponSingleCombobox.stories.tsx | 201 ++++++++ react/stories/comboboxDemo.tsx | 91 ++++ react/test/ComboboxSearch.spec.ts | 251 ++++++++++ react/test/IpponCheckbox.spec.tsx | 64 +++ react/test/IpponDropdown.spec.tsx | 17 + react/test/IpponInputSearch.spec.tsx | 89 ++++ react/test/IpponMultiCombobox.spec.tsx | 147 ++++++ react/test/IpponOption.spec.tsx | 112 +++++ react/test/IpponOptionList.spec.tsx | 120 +++++ react/test/IpponSingleCombobox.spec.tsx | 455 ++++++++++++++++++ styles/package.json | 2 +- styles/src/atom/_atom.scss | 2 + styles/src/atom/atom.pug | 2 + styles/src/atom/checkbox/_checkbox.scss | 54 +++ styles/src/atom/checkbox/checkbox.code.pug | 7 + styles/src/atom/checkbox/checkbox.md | 23 + styles/src/atom/checkbox/checkbox.mixin.pug | 18 + styles/src/atom/checkbox/checkbox.render.pug | 5 + .../src/atom/input-search/_input-search.scss | 53 ++ .../atom/input-search/input-search.code.pug | 12 + styles/src/atom/input-search/input-search.md | 31 ++ .../atom/input-search/input-search.mixin.pug | 25 + .../atom/input-search/input-search.render.pug | 5 + styles/src/atom/input-text/_input-text.scss | 81 +--- styles/src/atom/ion/ion.mixin.pug | 4 +- styles/src/molecule/_molecule.scss | 2 + styles/src/molecule/molecule.pug | 2 + .../molecule/option-list/_option-list.scss | 32 ++ .../molecule/option-list/option-list.code.pug | 30 ++ .../src/molecule/option-list/option-list.md | 26 + .../option-list/option-list.mixin.pug | 26 + .../option-list/option-list.render.pug | 5 + styles/src/molecule/option/_option.scss | 80 +++ styles/src/molecule/option/option.code.pug | 14 + styles/src/molecule/option/option.md | 28 ++ styles/src/molecule/option/option.mixin.pug | 24 + styles/src/molecule/option/option.render.pug | 4 + styles/src/organism/_organism.scss | 1 + styles/src/organism/combobox/_combobox.scss | 24 + .../src/organism/combobox/combobox.code.pug | 35 ++ styles/src/organism/combobox/combobox.md | 49 ++ .../src/organism/combobox/combobox.mixin.pug | 27 ++ .../src/organism/combobox/combobox.render.pug | 7 + styles/src/organism/dropdown/_dropdown.scss | 5 + styles/src/organism/dropdown/dropdown.md | 15 +- .../src/organism/dropdown/dropdown.mixin.pug | 5 +- styles/src/organism/organism.pug | 1 + styles/src/quark/_checkbox.scss | 20 + styles/src/quark/_control-box.scss | 80 +++ styles/src/quark/_visually-hidden.scss | 10 + 75 files changed, 3693 insertions(+), 87 deletions(-) create mode 100644 react/src/Combobox.ts create mode 100644 react/src/ComboboxActiveOption.ts create mode 100644 react/src/ComboboxDisclosure.ts create mode 100644 react/src/ComboboxField.ts create mode 100644 react/src/ComboboxOption.ts create mode 100644 react/src/ComboboxOptions.tsx create mode 100644 react/src/ComboboxSearch.ts create mode 100644 react/src/IpponCheckbox.tsx create mode 100644 react/src/IpponInputSearch.tsx create mode 100644 react/src/IpponMultiCombobox.tsx create mode 100644 react/src/IpponOption.tsx create mode 100644 react/src/IpponOptionList.tsx create mode 100644 react/src/IpponSingleCombobox.tsx create mode 100644 react/stories/IpponCheckbox.stories.tsx create mode 100644 react/stories/IpponInputSearch.stories.tsx create mode 100644 react/stories/IpponMultiCombobox.stories.tsx create mode 100644 react/stories/IpponOption.stories.tsx create mode 100644 react/stories/IpponOptionList.stories.tsx create mode 100644 react/stories/IpponSingleCombobox.stories.tsx create mode 100644 react/stories/comboboxDemo.tsx create mode 100644 react/test/ComboboxSearch.spec.ts create mode 100644 react/test/IpponCheckbox.spec.tsx create mode 100644 react/test/IpponInputSearch.spec.tsx create mode 100644 react/test/IpponMultiCombobox.spec.tsx create mode 100644 react/test/IpponOption.spec.tsx create mode 100644 react/test/IpponOptionList.spec.tsx create mode 100644 react/test/IpponSingleCombobox.spec.tsx create mode 100644 styles/src/atom/checkbox/_checkbox.scss create mode 100644 styles/src/atom/checkbox/checkbox.code.pug create mode 100644 styles/src/atom/checkbox/checkbox.md create mode 100644 styles/src/atom/checkbox/checkbox.mixin.pug create mode 100644 styles/src/atom/checkbox/checkbox.render.pug create mode 100644 styles/src/atom/input-search/_input-search.scss create mode 100644 styles/src/atom/input-search/input-search.code.pug create mode 100644 styles/src/atom/input-search/input-search.md create mode 100644 styles/src/atom/input-search/input-search.mixin.pug create mode 100644 styles/src/atom/input-search/input-search.render.pug create mode 100644 styles/src/molecule/option-list/_option-list.scss create mode 100644 styles/src/molecule/option-list/option-list.code.pug create mode 100644 styles/src/molecule/option-list/option-list.md create mode 100644 styles/src/molecule/option-list/option-list.mixin.pug create mode 100644 styles/src/molecule/option-list/option-list.render.pug create mode 100644 styles/src/molecule/option/_option.scss create mode 100644 styles/src/molecule/option/option.code.pug create mode 100644 styles/src/molecule/option/option.md create mode 100644 styles/src/molecule/option/option.mixin.pug create mode 100644 styles/src/molecule/option/option.render.pug create mode 100644 styles/src/organism/combobox/_combobox.scss create mode 100644 styles/src/organism/combobox/combobox.code.pug create mode 100644 styles/src/organism/combobox/combobox.md create mode 100644 styles/src/organism/combobox/combobox.mixin.pug create mode 100644 styles/src/organism/combobox/combobox.render.pug create mode 100644 styles/src/quark/_checkbox.scss create mode 100644 styles/src/quark/_control-box.scss create mode 100644 styles/src/quark/_visually-hidden.scss diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9c458..cc548db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ 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-18 — @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, `Home` / `End` jump to the first and last enabled option, `Enter` selects, `Escape` closes and leaves focus in the field; disabled options are skipped and the active option is scrolled into view. 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. +- `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, `items` with a single `toOption` mapper returning `{ key, label, description?, disabled? }`, a `state` slot for whatever the list shows beside its rows, a `footer` slot inside the scrolling area for a "load more" or an infinite-scroll sentinel, and `busy`, `variant`, `disabled` and `readOnly`. `IpponMultiCombobox` adds `onDeselect`, `onClear` 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. 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. +- `useComboboxSearch` React hook, beside the combobox rather than inside it: it owns the query lifecycle a picker needs and a component holding no data must not do. It debounces the keystrokes, cancels the request left in flight as soon as the reader types again or the field goes away, and drops an answer that comes back after the query moved on, so a slow reply can never overwrite a newer one. The search says what it returns and each shape means one thing: an array is served synchronously and is never reported busy, a bare promise is awaited, and a `{ promise, cancel }` pair is awaited and cancelled. The hook imposes no cancellation mechanism of its own — a caller whose data layer takes an `AbortSignal`, or an RxJS subscription, or nothing at all, hands back whatever stops it. It reports facts — `busy`, `failed`, `empty`, `retry` — and never a word of text nor a rendered node, so the wording and the states stay the caller's. Spread `search.combobox` into either combobox; a caller who already owns a query layer ignores the hook and passes `items` directly. +- `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 state 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. +- `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` and `-disabled` alternatives. 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. +- `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` alternative 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 covers anchoring a panel to something that is not an invoker button, and the `manual` popover such a panel needs. +- `IpponDropdown` React component: `alternative` and `popover` props, both defaulting to the current behaviour (`buttons` and `auto`). +- `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 2ff27b0..51f9a3e 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/Combobox.ts b/react/src/Combobox.ts new file mode 100644 index 0000000..d858d83 --- /dev/null +++ b/react/src/Combobox.ts @@ -0,0 +1,21 @@ +import type { KeyboardEventHandler, ReactNode } from 'react'; +import type { ComboboxOptionMapper } from './ComboboxOption.ts'; + +export type ComboboxProps = { + id: string; + query: string; + onQueryChange: (query: string) => void; + items: readonly Item[]; + toOption: ComboboxOptionMapper; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + variant?: 'error' | 'success'; + busy?: boolean; + state?: ReactNode; + footer?: ReactNode; + onKeyDown?: KeyboardEventHandler; + describedBy?: string; + labelledBy?: string; + className?: string; +}; diff --git a/react/src/ComboboxActiveOption.ts b/react/src/ComboboxActiveOption.ts new file mode 100644 index 0000000..314c9aa --- /dev/null +++ b/react/src/ComboboxActiveOption.ts @@ -0,0 +1,69 @@ +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; +import type { ComboboxEntry } from './ComboboxOption.ts'; +import { toEnabledEntries } from './ComboboxOption.ts'; + +export type ComboboxEdge = 'Home' | 'End'; + +export type ComboboxActiveOption = { + activeKey?: string; + activeEntry?: ComboboxEntry; + activate: (key: string) => void; + move: (delta: number) => void; + moveToEdge: (edge: ComboboxEdge) => void; +}; + +const toWrappedIndex = (currentIndex: number, delta: number, length: number): number => { + if (currentIndex === -1) { + return delta > 0 ? 0 : length - 1; + } + return (currentIndex + delta + length) % length; +}; + +const useScrollIntoView = ( + wrapperRef: RefObject, + expanded: boolean, + activeKey?: string, +) => { + useEffect(() => { + if (!expanded || activeKey === undefined) { + return; + } + wrapperRef.current + ?.querySelector('.ippon-option.-active') + ?.scrollIntoView?.({ block: 'nearest' }); + }, [wrapperRef, expanded, activeKey]); +}; + +export const useComboboxActiveOption = ( + entries: readonly ComboboxEntry[], + expanded: boolean, + wrapperRef: RefObject, +): ComboboxActiveOption => { + const [activeKey, setActiveKey] = useState(undefined); + + const enabledEntries = toEnabledEntries(entries); + const activeEntry = enabledEntries.find((entry) => entry.option.key === activeKey); + + useScrollIntoView(wrapperRef, expanded, activeKey); + + const moveTo = (index: number) => { + if (enabledEntries.length > 0) { + setActiveKey(enabledEntries[index].option.key); + } + }; + + return { + activeKey, + activeEntry, + activate: setActiveKey, + move: (delta: number) => { + if (enabledEntries.length === 0) { + return; + } + const currentIndex = enabledEntries.findIndex((entry) => entry.option.key === activeKey); + moveTo(toWrappedIndex(currentIndex, delta, enabledEntries.length)); + }, + moveToEdge: (edge: ComboboxEdge) => moveTo(edge === 'Home' ? 0 : enabledEntries.length - 1), + }; +}; diff --git a/react/src/ComboboxDisclosure.ts b/react/src/ComboboxDisclosure.ts new file mode 100644 index 0000000..a7cc048 --- /dev/null +++ b/react/src/ComboboxDisclosure.ts @@ -0,0 +1,78 @@ +import type { RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type ComboboxDisclosure = { + wrapperRef: RefObject; + expanded: boolean; + expand: () => void; + collapse: () => void; +}; + +const toPanel = (wrapper: HTMLDivElement | null): HTMLElement | undefined => + wrapper?.querySelector('.ippon-combobox--list') ?? undefined; + +const usePopoverSync = (wrapperRef: RefObject, expanded: boolean) => { + useEffect(() => { + const panel = toPanel(wrapperRef.current); + if (!panel?.showPopover) { + return; + } + const opened = panel.matches(':popover-open'); + if (expanded && !opened) { + panel.showPopover(); + } + if (!expanded && opened) { + panel.hidePopover(); + } + }, [wrapperRef, expanded]); +}; + +const useDismissal = ( + wrapperRef: RefObject, + expanded: boolean, + collapse: () => void, +) => { + useEffect(() => { + if (!expanded) { + return; + } + const collapseOnOutsidePointer = (event: PointerEvent) => { + const wrapper = wrapperRef.current; + if (wrapper === null || wrapper.contains(event.target as Node)) { + return; + } + collapse(); + }; + const collapseOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + collapse(); + } + }; + document.addEventListener('pointerdown', collapseOnOutsidePointer); + document.addEventListener('keydown', collapseOnEscape); + return () => { + document.removeEventListener('pointerdown', collapseOnOutsidePointer); + document.removeEventListener('keydown', collapseOnEscape); + }; + }, [wrapperRef, expanded, collapse]); +}; + +export const useComboboxDisclosure = (interactive: boolean): ComboboxDisclosure => { + const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + const expanded = open && interactive; + + const collapse = useCallback(() => setOpen(false), []); + + const expand = () => { + if (interactive) { + setOpen(true); + } + }; + + usePopoverSync(wrapperRef, expanded); + useDismissal(wrapperRef, expanded, collapse); + + return { wrapperRef, expanded, expand, collapse }; +}; diff --git a/react/src/ComboboxField.ts b/react/src/ComboboxField.ts new file mode 100644 index 0000000..346a958 --- /dev/null +++ b/react/src/ComboboxField.ts @@ -0,0 +1,86 @@ +import type { + ChangeEvent, + FocusEvent, + KeyboardEvent as ReactKeyboardEvent, + KeyboardEventHandler, + PointerEvent as ReactPointerEvent, +} from 'react'; +import type { ComboboxActiveOption } from './ComboboxActiveOption.ts'; +import type { ComboboxDisclosure } from './ComboboxDisclosure.ts'; +import type { ComboboxEntry } from './ComboboxOption.ts'; + +type ComboboxFieldInput = { + interactive: boolean; + disclosure: ComboboxDisclosure; + active: ComboboxActiveOption; + onQueryChange: (query: string) => void; + onPick: (entry: ComboboxEntry) => void; + onKeyDown?: KeyboardEventHandler; +}; + +export type ComboboxFieldHandlers = { + onChange: (event: ChangeEvent) => void; + onKeyDown: (event: ReactKeyboardEvent) => void; + onPointerDown: (event: ReactPointerEvent) => void; + onBlur: (event: FocusEvent) => void; +}; + +const leftTheDocument = (event: FocusEvent): boolean => + event.relatedTarget === null && !(event.target as Element).isConnected; + +export const useComboboxField = ({ + interactive, + disclosure, + active, + onQueryChange, + onPick, + onKeyDown, +}: ComboboxFieldInput): ComboboxFieldHandlers => { + const { expanded, expand, collapse } = disclosure; + + const handleListKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + active.moveToEdge(event.key); + return; + } + if (event.key === 'Enter' && active.activeEntry !== undefined) { + event.preventDefault(); + onPick(active.activeEntry); + } + }; + + return { + onChange: (event) => { + expand(); + onQueryChange(event.target.value); + }, + onKeyDown: (event) => { + onKeyDown?.(event); + if (event.defaultPrevented || !interactive) { + return; + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + expand(); + active.move(event.key === 'ArrowDown' ? 1 : -1); + return; + } + if (expanded) { + handleListKeyDown(event); + } + }, + onPointerDown: (event) => { + if (expanded || (event.target as Element).closest('.ippon-combobox--control') === null) { + return; + } + expand(); + }, + onBlur: (event) => { + if (event.currentTarget.contains(event.relatedTarget) || leftTheDocument(event)) { + return; + } + collapse(); + }, + }; +}; diff --git a/react/src/ComboboxOption.ts b/react/src/ComboboxOption.ts new file mode 100644 index 0000000..e6cc6dc --- /dev/null +++ b/react/src/ComboboxOption.ts @@ -0,0 +1,32 @@ +export type IpponComboboxOption = { + key: string; + label: string; + description?: string; + disabled?: boolean; +}; + +export type ComboboxEntry = { + item: Item; + option: IpponComboboxOption; +}; + +export type ComboboxOptionMapper = (item: Item) => IpponComboboxOption; + +export const toEntries = ( + items: readonly Item[], + toOption: ComboboxOptionMapper, +): ComboboxEntry[] => items.map((item) => ({ item, option: toOption(item) })); + +export const toEnabledEntries = ( + entries: readonly ComboboxEntry[], +): ComboboxEntry[] => entries.filter((entry) => !entry.option.disabled); + +export const toSelectedKeys = ( + selection: readonly Item[], + toOption: ComboboxOptionMapper, +): ReadonlySet => new Set(selection.map((item) => toOption(item).key)); + +export const toJoinedLabels = ( + selection: readonly Item[], + toOption: ComboboxOptionMapper, +): string => selection.map((item) => toOption(item).label).join(', '); diff --git a/react/src/ComboboxOptions.tsx b/react/src/ComboboxOptions.tsx new file mode 100644 index 0000000..0ac04b1 --- /dev/null +++ b/react/src/ComboboxOptions.tsx @@ -0,0 +1,40 @@ +import { toChildSelector } from './DataSelectable.ts'; +import type { ComboboxEntry } from './ComboboxOption.ts'; +import { IpponOption } from './IpponOption.tsx'; + +type ComboboxOptionsProps = { + id: string; + entries: readonly ComboboxEntry[]; + selectedKeys: ReadonlySet; + single: boolean; + activeKey?: string; + dataSelector?: string; + onActivate: (key: string) => void; + onPick: (entry: ComboboxEntry) => void; +}; + +export const ComboboxOptions = (props: ComboboxOptionsProps) => + props.entries.map((entry) => ( + event.preventDefault()} + onPointerEnter={() => { + if (!entry.option.disabled) { + props.onActivate(entry.option.key); + } + }} + onClick={() => { + if (!entry.option.disabled) { + props.onPick(entry); + } + }} + /> + )); diff --git a/react/src/ComboboxSearch.ts b/react/src/ComboboxSearch.ts new file mode 100644 index 0000000..6c9c0ec --- /dev/null +++ b/react/src/ComboboxSearch.ts @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type ComboboxCancellableSearch = { + promise: Promise; + cancel: () => void; +}; + +export type ComboboxSearchResult = + | readonly Item[] + | Promise + | ComboboxCancellableSearch; + +export type ComboboxSearch = (query: string) => ComboboxSearchResult; + +export type ComboboxSearchInput = { + search: ComboboxSearch; + initialQuery?: string; + debounce?: number; +}; + +export type ComboboxSearchBinding = { + query: string; + onQueryChange: (query: string) => void; + items: readonly Item[]; + busy: boolean; +}; + +export type ComboboxSearchState = { + combobox: ComboboxSearchBinding; + failed: boolean; + empty: boolean; + retry: () => void; +}; + +const defaultDebounce = 300; + +type ComboboxPendingSearch = Promise | ComboboxCancellableSearch; + +const isCancellable = ( + pending: ComboboxPendingSearch, +): pending is ComboboxCancellableSearch => 'promise' in pending; + +export const useComboboxSearch = ({ + search, + initialQuery = '', + debounce = defaultDebounce, +}: ComboboxSearchInput): ComboboxSearchState => { + const [query, setQuery] = useState(initialQuery); + const [items, setItems] = useState([]); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + + const searchRef = useRef(search); + + useEffect(() => { + searchRef.current = search; + }, [search]); + + useEffect(() => { + let dropped = false; + let cancel: (() => void) | undefined; + + const settle = (found: readonly Item[]) => { + setItems(found); + setBusy(false); + setFailed(false); + }; + + const fail = () => { + setBusy(false); + setFailed(true); + }; + + const follow = (promise: Promise) => { + setBusy(true); + setFailed(false); + promise.then( + (found) => { + if (!dropped) { + settle(found); + } + }, + () => { + if (!dropped) { + fail(); + } + }, + ); + }; + + const run = () => { + let result: ComboboxSearchResult; + try { + result = searchRef.current(query); + } catch { + fail(); + return; + } + if (Array.isArray(result)) { + settle(result); + return; + } + const pending = result as ComboboxPendingSearch; + if (isCancellable(pending)) { + cancel = pending.cancel; + follow(pending.promise); + return; + } + follow(pending); + }; + + const stop = () => { + dropped = true; + cancel?.(); + }; + + if (debounce === 0) { + run(); + return stop; + } + + const timer = setTimeout(run, debounce); + return () => { + clearTimeout(timer); + stop(); + }; + }, [query, attempt, debounce]); + + const retry = useCallback(() => setAttempt((previous) => previous + 1), []); + + return { + combobox: { query, onQueryChange: setQuery, items, busy }, + failed, + empty: !busy && !failed && items.length === 0, + retry, + }; +}; 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..866da60 100644 --- a/react/src/IpponDropdown.tsx +++ b/react/src/IpponDropdown.tsx @@ -4,6 +4,8 @@ import type { DataSelectableWithChildren } from './DataSelectable.ts'; type IpponDropdownProps = DataSelectableWithChildren<{ id: string; + alternative?: 'buttons' | 'options'; + popover?: 'auto' | 'manual'; className?: string; onKeyDown?: KeyboardEventHandler; onToggle?: ToggleEventHandler; @@ -12,8 +14,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) => (