sinwan-ui-core 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this package are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package uses [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [1.2.0]
6
+
7
+ ### Added
8
+
9
+ - `RadioGroupRoot` / `RadioGroupItem` (`controls`) follow the WAI-ARIA radio group keyboard pattern. The group has a single tab stop: the checked radio, or the first enabled one when nothing is checked. ArrowDown/ArrowRight select and focus the next enabled radio, and ArrowUp/ArrowLeft the previous one. Both wrap and skip disabled radios, and ArrowLeft/ArrowRight swap inside a right-to-left `DirectionProvider`. Click and Space are unchanged. Before, every radio was its own tab stop and arrow keys did nothing.
10
+ - `RadioGroupKey` gains `register`, `tabStop` and `move`, and the `RadioGroupEntry` type is exported. Code that only reads `value` and `setValue` is unaffected.
11
+
12
+ ## [1.1.1]
13
+
14
+ ### Fixed
15
+
16
+ - `ToggleGroupRoot` (`controls`) forwards `aria-*` attributes to its `role="group"` element. Before, an `aria-label` such as "Billing period" was dropped, so screen readers announced an unnamed group. Values stay live when driven by a signal or getter.
17
+
5
18
  ## [1.1.0]
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -24,7 +24,7 @@ Import from the root barrel or from a subpath (subpaths keep bundles small):
24
24
  | --- | --- |
25
25
  | `sinwan-ui-core/core` | `Presence` (keeps content mounted through CSS exit), `UiPortal`, `useControllableState`, `getFocusableElements`, `trapFocus`; re-exports the positioning helpers from `floating` |
26
26
  | `sinwan-ui-core/overlay` | Dialog, Collapsible, Popover and Tooltip primitives (`DialogRoot`, `DialogTrigger`, `DialogOverlay`, `DialogContent`, `PopoverRoot`, …), their injection keys and `OpenApi` |
27
- | `sinwan-ui-core/controls` | Tabs, Accordion, Checkbox, Switch, RadioGroup, Toggle and ToggleGroup primitives with injection keys |
27
+ | `sinwan-ui-core/controls` | Tabs, Accordion, Checkbox, Switch, RadioGroup (WAI-ARIA arrow keys and a single tab stop), Toggle and ToggleGroup primitives with injection keys |
28
28
  | `sinwan-ui-core/floating` | `useAnchorPosition`, `usePointPosition`, `computePosition`, `estimatePosition` and placement helpers (Floating UI based) |
29
29
  | `sinwan-ui-core/dismiss` | `isDismissExemptPointerTarget` for outside-press handling across portaled layers |
30
30
  | `sinwan-ui-core/menu-sub` | `createMenuSubApi` (closes with its `parentOpen`), `createMenuSubTriggerHandlers`, `isInsideMenuSubContent`, `MenuSubContentLayer` for nested menus |
@@ -80,9 +80,9 @@ function writeDomPalette(next) {
80
80
  document.documentElement.setAttribute("data-palette", next);
81
81
  }
82
82
  var ThemeProvider2 = cc((props) => {
83
- const allowedPalettes = (props.palettes === undefined ? DEFAULT_PALETTES : props.palettes).length > 0 ? props.palettes === undefined ? DEFAULT_PALETTES : props.palettes : DEFAULT_PALETTES;
83
+ const allowedPalettes = () => (props.palettes === undefined ? DEFAULT_PALETTES : props.palettes).length > 0 ? props.palettes === undefined ? DEFAULT_PALETTES : props.palettes : DEFAULT_PALETTES;
84
84
  const initialTheme = props.forcedTheme ?? readStoredTheme(props.storageKey === undefined ? "sinwan-ui-theme" : props.storageKey, props.defaultTheme === undefined ? "system" : props.defaultTheme);
85
- const initialPalette = props.forcedPalette ?? readStoredPalette(props.paletteStorageKey === undefined ? "sinwan-ui-palette" : props.paletteStorageKey, props.defaultPalette === undefined ? "credit-blue" : props.defaultPalette, allowedPalettes);
85
+ const initialPalette = props.forcedPalette ?? readStoredPalette(props.paletteStorageKey === undefined ? "sinwan-ui-palette" : props.paletteStorageKey, props.defaultPalette === undefined ? "credit-blue" : props.defaultPalette, allowedPalettes());
86
86
  const theme = signal(initialTheme);
87
87
  const palette = signal(initialPalette);
88
88
  const resolved = signal(initialTheme === "system" ? getSystemTheme() : initialTheme);
@@ -131,7 +131,7 @@ var ThemeProvider2 = cc((props) => {
131
131
  const setPalette = (value) => {
132
132
  if (props.forcedPalette)
133
133
  return;
134
- if (!allowedPalettes.includes(value))
134
+ if (!allowedPalettes().includes(value))
135
135
  return;
136
136
  try {
137
137
  localStorage.setItem(props.paletteStorageKey === undefined ? "sinwan-ui-palette" : props.paletteStorageKey, value);
@@ -159,7 +159,7 @@ var ThemeProvider2 = cc((props) => {
159
159
  themes: (props.enableSystem === undefined ? true : props.enableSystem) ? ["light", "dark", "system"] : ["light", "dark"],
160
160
  palette,
161
161
  setPalette,
162
- palettes: allowedPalettes
162
+ palettes: allowedPalettes()
163
163
  });
164
164
  computed(() => resolved.value);
165
165
  computed(() => palette.value);
@@ -1,11 +1,20 @@
1
1
  import {
2
2
  Presence2
3
3
  } from "./chunk-ejwby1pn.js";
4
+ import {
5
+ DirectionKey2
6
+ } from "./chunk-f6hw8ash.js";
4
7
 
5
8
  // src/controls.tsx
6
9
  import { _$createTemplate } from "sinwan/renderer/web";
7
10
  import { unwrap as _$unwrap } from "sinwan/reactivity";
8
- import { cc, inject, provide } from "sinwan/component";
11
+ import {
12
+ cc,
13
+ getRawProps,
14
+ inject,
15
+ onUnmounted,
16
+ provide
17
+ } from "sinwan/component";
9
18
  import { For as For2, Show as Show2 } from "sinwan/control-flow";
10
19
  import { computed, signal } from "sinwan/reactivity";
11
20
  import { jsx } from "sinwan/jsx-runtime";
@@ -98,14 +107,17 @@ var _$tmpl_8 = {
98
107
  ]
99
108
  };
100
109
  var _$tmpl_9 = {
101
- html: '<button type="button" role="radio" id="" data-slot="radio-group-item" data-state="" aria-checked="" disabled=""></button>',
110
+ html: '<button type="button" role="radio" id="" data-slot="radio-group-item" data-state="" aria-checked="" tabIndex="" disabled=""></button>',
102
111
  slots: [
112
+ { path: [], type: "ref", name: "ref" },
103
113
  { path: [], type: "attr", name: "id" },
104
114
  { path: [], type: "attr", name: "data-state" },
105
115
  { path: [], type: "attr", name: "aria-checked" },
116
+ { path: [], type: "attr", name: "tabIndex" },
106
117
  { path: [], type: "attr", name: "disabled" },
107
118
  { path: [], type: "attr", name: "class" },
108
119
  { path: [], type: "event", name: "onclick" },
120
+ { path: [], type: "event", name: "onkeydown" },
109
121
  { path: [], type: "child" }
110
122
  ]
111
123
  };
@@ -121,18 +133,6 @@ var _$tmpl_10 = {
121
133
  ]
122
134
  };
123
135
  var _$tmpl_11 = {
124
- html: '<div data-slot="toggle-group" role="group" style="" data-variant="" data-size="" data-spacing="" data-orientation=""></div>',
125
- slots: [
126
- { path: [], type: "attr", name: "class" },
127
- { path: [], type: "attr", name: "style" },
128
- { path: [], type: "attr", name: "data-variant" },
129
- { path: [], type: "attr", name: "data-size" },
130
- { path: [], type: "attr", name: "data-spacing" },
131
- { path: [], type: "attr", name: "data-orientation" },
132
- { path: [], type: "child" }
133
- ]
134
- };
135
- var _$tmpl_12 = {
136
136
  html: '<button type="button" data-slot="toggle-group-item" data-variant="" data-size="" data-spacing="" data-state="" disabled=""></button>',
137
137
  slots: [
138
138
  { path: [], type: "attr", name: "data-variant" },
@@ -342,6 +342,10 @@ var SwitchRoot2 = cc((props) => {
342
342
  ]);
343
343
  });
344
344
  var RadioGroupKey2 = Symbol("sinwan-ui.radio-group");
345
+ var DOCUMENT_POSITION_FOLLOWING = 4;
346
+ function inDocumentOrder(a, b) {
347
+ return a.el.compareDocumentPosition(b.el) & DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
348
+ }
345
349
  var RadioGroupRoot2 = cc((props) => {
346
350
  const valueLocal = signal(props.defaultValue ?? "");
347
351
  const value = "value" in props ? computed(() => props.value ?? "") : valueLocal;
@@ -349,28 +353,81 @@ var RadioGroupRoot2 = cc((props) => {
349
353
  if (!("value" in props))
350
354
  valueLocal.value = next;
351
355
  };
356
+ const setValue = (v) => {
357
+ set(v);
358
+ props.onValueChange?.(v);
359
+ };
360
+ const entries = signal([]);
361
+ const enabled = () => entries.value.filter((entry) => !entry.disabled());
362
+ const tabStop = computed(() => {
363
+ const list = enabled();
364
+ return (list.find((entry) => entry.value() === value.value) ?? list[0])?.value();
365
+ });
352
366
  provide(RadioGroupKey2, {
353
367
  value,
354
368
  name: props.name,
355
- setValue: (v) => {
356
- set(v);
357
- props.onValueChange?.(v);
369
+ setValue,
370
+ register: (entry) => {
371
+ entries.value = [...entries.value, entry];
372
+ return () => {
373
+ entries.value = entries.value.filter((other) => other !== entry);
374
+ };
375
+ },
376
+ tabStop,
377
+ move: (from, step) => {
378
+ const list = enabled().sort(inDocumentOrder);
379
+ if (list.length === 0)
380
+ return;
381
+ const index = list.findIndex((entry) => entry.value() === from);
382
+ const next = index === -1 ? list[step === 1 ? 0 : list.length - 1] : list[(index + step + list.length) % list.length];
383
+ next.el.focus();
384
+ setValue(next.value());
358
385
  }
359
386
  });
360
387
  return _$createTemplate(_$tmpl_8, [() => _$unwrap(props).class, () => _$unwrap(props).children]);
361
388
  });
389
+ var LTR = { dir: { value: "ltr" } };
390
+ function radioStep(key, dir) {
391
+ const forward = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
392
+ const backward = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
393
+ if (key === "ArrowDown" || key === forward)
394
+ return 1;
395
+ if (key === "ArrowUp" || key === backward)
396
+ return -1;
397
+ return 0;
398
+ }
362
399
  var RadioGroupItem2 = cc((props) => {
363
400
  const api = inject(RadioGroupKey2);
401
+ const direction = inject(DirectionKey2, LTR);
402
+ let unregister;
403
+ onUnmounted(() => unregister?.());
364
404
  return _$createTemplate(_$tmpl_9, [
405
+ (el) => {
406
+ if (el && !unregister) {
407
+ unregister = api.register({
408
+ value: () => props.value,
409
+ disabled: () => Boolean(props.disabled),
410
+ el
411
+ });
412
+ }
413
+ },
365
414
  () => _$unwrap(props).id,
366
415
  () => api.value.value === _$unwrap(props).value ? "checked" : "unchecked",
367
416
  () => api.value.value === _$unwrap(props).value,
417
+ () => api.tabStop.value === _$unwrap(props).value ? 0 : -1,
368
418
  () => _$unwrap(props).disabled,
369
419
  () => _$unwrap(props).class,
370
420
  () => {
371
421
  if (!props.disabled)
372
422
  api.setValue(props.value);
373
423
  },
424
+ (event) => {
425
+ const step = radioStep(event.key, direction.dir.value);
426
+ if (step === 0)
427
+ return;
428
+ event.preventDefault();
429
+ api.move(props.value, step);
430
+ },
374
431
  () => _$unwrap(props).children
375
432
  ]);
376
433
  });
@@ -399,6 +456,7 @@ var ToggleRoot2 = cc((props) => {
399
456
  var ToggleGroupKey2 = Symbol("sinwan-ui.toggle-group");
400
457
  var ToggleGroupRoot2 = cc((props) => {
401
458
  const type = () => props.type ?? "single";
459
+ const ariaProps = Object.fromEntries(Object.entries(getRawProps(props)).filter(([key]) => key.startsWith("aria-")));
402
460
  const valueLocal = signal(toValueList(props.defaultValue));
403
461
  const value = "value" in props ? computed(() => toValueList(props.value)) : valueLocal;
404
462
  const set = (next) => {
@@ -419,19 +477,22 @@ var ToggleGroupRoot2 = cc((props) => {
419
477
  props.onValueChange?.(type() === "single" ? next[0] ?? "" : next);
420
478
  }
421
479
  });
422
- return _$createTemplate(_$tmpl_11, [
423
- () => _$unwrap(props).class,
424
- () => _$unwrap(props).style,
425
- () => props["data-variant"],
426
- () => props["data-size"],
427
- () => props["data-spacing"],
428
- () => props["data-orientation"],
429
- () => _$unwrap(props).children
430
- ]);
480
+ return /* @__PURE__ */ jsx("div", {
481
+ "data-slot": "toggle-group",
482
+ role: "group",
483
+ class: () => _$unwrap(props).class,
484
+ style: () => _$unwrap(props).style,
485
+ "data-variant": () => props["data-variant"],
486
+ "data-size": () => props["data-size"],
487
+ "data-spacing": () => props["data-spacing"],
488
+ "data-orientation": () => props["data-orientation"],
489
+ ...ariaProps,
490
+ children: () => _$unwrap(props).children
491
+ });
431
492
  });
432
493
  var ToggleGroupItem2 = cc((props) => {
433
494
  const api = inject(ToggleGroupKey2);
434
- return _$createTemplate(_$tmpl_12, [
495
+ return _$createTemplate(_$tmpl_11, [
435
496
  () => props["data-variant"],
436
497
  () => props["data-size"],
437
498
  () => props["data-spacing"],
@@ -83,10 +83,22 @@ export declare const SwitchRoot: import("sinwan/component").SinwanComponent<{
83
83
  children?: SinwanNode;
84
84
  "data-size"?: string;
85
85
  }>;
86
+ /** A radio registered with its group for roving focus. */
87
+ export type RadioGroupEntry = {
88
+ value: () => string;
89
+ disabled: () => boolean;
90
+ el: HTMLElement;
91
+ };
86
92
  export declare const RadioGroupKey: InjectionKey<{
87
93
  value: Signal<string> | Computed<string>;
88
94
  setValue: (v: string) => void;
89
95
  name?: string;
96
+ /** Adds a radio to the group's keyboard order; returns its removal. */
97
+ register: (entry: RadioGroupEntry) => () => void;
98
+ /** Value of the one radio that is in the tab order. */
99
+ tabStop: Computed<string | undefined>;
100
+ /** Moves focus and selection `step` enabled radios from `from`, wrapping. */
101
+ move: (from: string, step: 1 | -1) => void;
90
102
  }>;
91
103
  export declare const RadioGroupRoot: import("sinwan/component").SinwanComponent<{
92
104
  children?: SinwanNode;
@@ -117,6 +129,7 @@ export declare const ToggleGroupKey: InjectionKey<{
117
129
  toggle: (v: string) => void;
118
130
  }>;
119
131
  export declare const ToggleGroupRoot: import("sinwan/component").SinwanComponent<{
132
+ [key: `aria-${string}`]: string | boolean | undefined;
120
133
  children?: SinwanNode;
121
134
  type?: "single" | "multiple";
122
135
  value?: string | string[];
package/dist/controls.js CHANGED
@@ -21,7 +21,8 @@ import {
21
21
  ToggleGroupKey2,
22
22
  ToggleGroupRoot2,
23
23
  ToggleGroupItem2
24
- } from "./chunks/chunk-te52bwb8.js";
24
+ } from "./chunks/chunk-s03me75x.js";
25
+ import"./chunks/chunk-f6hw8ash.js";
25
26
  import {
26
27
  Slot2
27
28
  } from "./chunks/chunk-bw9vp0y9.js";
package/dist/index.js CHANGED
@@ -26,6 +26,11 @@ import {
26
26
  getFocusableElements2,
27
27
  trapFocus2
28
28
  } from "./chunks/chunk-ejwby1pn.js";
29
+ import {
30
+ DirectionKey2,
31
+ DirectionProvider2,
32
+ useDirection2
33
+ } from "./chunks/chunk-f6hw8ash.js";
29
34
  import {
30
35
  For2,
31
36
  TabsKey2,
@@ -48,12 +53,7 @@ import {
48
53
  ToggleGroupKey2,
49
54
  ToggleGroupRoot2,
50
55
  ToggleGroupItem2
51
- } from "./chunks/chunk-te52bwb8.js";
52
- import {
53
- DirectionKey2,
54
- DirectionProvider2,
55
- useDirection2
56
- } from "./chunks/chunk-f6hw8ash.js";
56
+ } from "./chunks/chunk-s03me75x.js";
57
57
  import {
58
58
  isDismissExemptPointerTarget2
59
59
  } from "./chunks/chunk-9whagkb6.js";
@@ -89,7 +89,7 @@ import {
89
89
  ThemeProvider2,
90
90
  useTheme2,
91
91
  ThemeToggle2
92
- } from "./chunks/chunk-3zrktrd2.js";
92
+ } from "./chunks/chunk-33f19xws.js";
93
93
  export {
94
94
  AccordionContent2 as AccordionContent,
95
95
  AccordionItem2 as AccordionItem,
package/dist/theme.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  ThemeProvider2,
4
4
  useTheme2,
5
5
  ThemeToggle2
6
- } from "./chunks/chunk-3zrktrd2.js";
6
+ } from "./chunks/chunk-33f19xws.js";
7
7
  export {
8
8
  ThemeKey2 as ThemeKey,
9
9
  ThemeProvider2 as ThemeProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinwan-ui-core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Headless behaviour primitives for Sinwan UI components: overlays, floating positioning, dismiss layers, controls, slots, theme and direction.",
5
5
  "author": "Mohammed Ben Cheikh",
6
6
  "license": "MIT",
@@ -107,7 +107,7 @@
107
107
  },
108
108
  "devDependencies": {
109
109
  "@types/bun": "latest",
110
- "bun-plugin-sinwan": "1.0.0",
110
+ "bun-plugin-sinwan": "^1.0.1",
111
111
  "happy-dom": "^20.14.5",
112
112
  "sinwan": "^2.0.3",
113
113
  "typescript": "^5.9.3"
@@ -115,4 +115,4 @@
115
115
  "peerDependencies": {
116
116
  "sinwan": ">=2.0.3 <3.0.0"
117
117
  }
118
- }
118
+ }