Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions react/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
21 changes: 21 additions & 0 deletions react/src/Combobox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { KeyboardEventHandler, ReactNode } from 'react';
import type { ComboboxOptionMapper } from './ComboboxOption.ts';

export type ComboboxProps<Item> = {
id: string;
query: string;
onQueryChange: (query: string) => void;
items: readonly Item[];
toOption: ComboboxOptionMapper<Item>;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
variant?: 'error' | 'success';
busy?: boolean;
state?: ReactNode;
footer?: ReactNode;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
describedBy?: string;
labelledBy?: string;
className?: string;
};
69 changes: 69 additions & 0 deletions react/src/ComboboxActiveOption.ts
Original file line number Diff line number Diff line change
@@ -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<Item> = {
activeKey?: string;
activeEntry?: ComboboxEntry<Item>;
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<HTMLDivElement | null>,
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 = <Item>(
entries: readonly ComboboxEntry<Item>[],
expanded: boolean,
wrapperRef: RefObject<HTMLDivElement | null>,
): ComboboxActiveOption<Item> => {
const [activeKey, setActiveKey] = useState<string | undefined>(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),
};
};
78 changes: 78 additions & 0 deletions react/src/ComboboxDisclosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

export type ComboboxDisclosure = {
wrapperRef: RefObject<HTMLDivElement | null>;
expanded: boolean;
expand: () => void;
collapse: () => void;
};

const toPanel = (wrapper: HTMLDivElement | null): HTMLElement | undefined =>
wrapper?.querySelector<HTMLElement>('.ippon-combobox--list') ?? undefined;

const usePopoverSync = (wrapperRef: RefObject<HTMLDivElement | null>, 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<HTMLDivElement | null>,
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<HTMLDivElement>(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 };
};
86 changes: 86 additions & 0 deletions react/src/ComboboxField.ts
Original file line number Diff line number Diff line change
@@ -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<Item> = {
interactive: boolean;
disclosure: ComboboxDisclosure;
active: ComboboxActiveOption<Item>;
onQueryChange: (query: string) => void;
onPick: (entry: ComboboxEntry<Item>) => void;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
};

export type ComboboxFieldHandlers = {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onBlur: (event: FocusEvent<HTMLDivElement>) => void;
};

const leftTheDocument = (event: FocusEvent<HTMLDivElement>): boolean =>
event.relatedTarget === null && !(event.target as Element).isConnected;

export const useComboboxField = <Item>({
interactive,
disclosure,
active,
onQueryChange,
onPick,
onKeyDown,
}: ComboboxFieldInput<Item>): ComboboxFieldHandlers => {
const { expanded, expand, collapse } = disclosure;

const handleListKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
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();
},
};
};
32 changes: 32 additions & 0 deletions react/src/ComboboxOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type IpponComboboxOption = {
key: string;
label: string;
description?: string;
disabled?: boolean;
};

export type ComboboxEntry<Item> = {
item: Item;
option: IpponComboboxOption;
};

export type ComboboxOptionMapper<Item> = (item: Item) => IpponComboboxOption;

export const toEntries = <Item>(
items: readonly Item[],
toOption: ComboboxOptionMapper<Item>,
): ComboboxEntry<Item>[] => items.map((item) => ({ item, option: toOption(item) }));

export const toEnabledEntries = <Item>(
entries: readonly ComboboxEntry<Item>[],
): ComboboxEntry<Item>[] => entries.filter((entry) => !entry.option.disabled);

export const toSelectedKeys = <Item>(
selection: readonly Item[],
toOption: ComboboxOptionMapper<Item>,
): ReadonlySet<string> => new Set(selection.map((item) => toOption(item).key));

export const toJoinedLabels = <Item>(
selection: readonly Item[],
toOption: ComboboxOptionMapper<Item>,
): string => selection.map((item) => toOption(item).label).join(', ');
40 changes: 40 additions & 0 deletions react/src/ComboboxOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { toChildSelector } from './DataSelectable.ts';
import type { ComboboxEntry } from './ComboboxOption.ts';
import { IpponOption } from './IpponOption.tsx';

type ComboboxOptionsProps<Item> = {
id: string;
entries: readonly ComboboxEntry<Item>[];
selectedKeys: ReadonlySet<string>;
single: boolean;
activeKey?: string;
dataSelector?: string;
onActivate: (key: string) => void;
onPick: (entry: ComboboxEntry<Item>) => void;
};

export const ComboboxOptions = <Item,>(props: ComboboxOptionsProps<Item>) =>
props.entries.map((entry) => (
<IpponOption
key={entry.option.key}
id={`${props.id}-option-${entry.option.key}`}
label={entry.option.label}
description={entry.option.description}
selected={props.selectedKeys.has(entry.option.key)}
active={entry.option.key === props.activeKey}
disabled={entry.option.disabled}
single={props.single}
dataSelector={toChildSelector(`option.${entry.option.key}`)(props.dataSelector)}
onMouseDown={(event) => event.preventDefault()}
onPointerEnter={() => {
if (!entry.option.disabled) {
props.onActivate(entry.option.key);
}
}}
onClick={() => {
if (!entry.option.disabled) {
props.onPick(entry);
}
}}
/>
));
Loading
Loading