sinwan-ui-core 1.1.1 → 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,13 @@
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
+
5
12
  ## [1.1.1]
6
13
 
7
14
  ### Fixed
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 |
@@ -1,6 +1,9 @@
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";
@@ -9,6 +12,7 @@ import {
9
12
  cc,
10
13
  getRawProps,
11
14
  inject,
15
+ onUnmounted,
12
16
  provide
13
17
  } from "sinwan/component";
14
18
  import { For as For2, Show as Show2 } from "sinwan/control-flow";
@@ -103,14 +107,17 @@ var _$tmpl_8 = {
103
107
  ]
104
108
  };
105
109
  var _$tmpl_9 = {
106
- 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>',
107
111
  slots: [
112
+ { path: [], type: "ref", name: "ref" },
108
113
  { path: [], type: "attr", name: "id" },
109
114
  { path: [], type: "attr", name: "data-state" },
110
115
  { path: [], type: "attr", name: "aria-checked" },
116
+ { path: [], type: "attr", name: "tabIndex" },
111
117
  { path: [], type: "attr", name: "disabled" },
112
118
  { path: [], type: "attr", name: "class" },
113
119
  { path: [], type: "event", name: "onclick" },
120
+ { path: [], type: "event", name: "onkeydown" },
114
121
  { path: [], type: "child" }
115
122
  ]
116
123
  };
@@ -335,6 +342,10 @@ var SwitchRoot2 = cc((props) => {
335
342
  ]);
336
343
  });
337
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
+ }
338
349
  var RadioGroupRoot2 = cc((props) => {
339
350
  const valueLocal = signal(props.defaultValue ?? "");
340
351
  const value = "value" in props ? computed(() => props.value ?? "") : valueLocal;
@@ -342,28 +353,81 @@ var RadioGroupRoot2 = cc((props) => {
342
353
  if (!("value" in props))
343
354
  valueLocal.value = next;
344
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
+ });
345
366
  provide(RadioGroupKey2, {
346
367
  value,
347
368
  name: props.name,
348
- setValue: (v) => {
349
- set(v);
350
- 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());
351
385
  }
352
386
  });
353
387
  return _$createTemplate(_$tmpl_8, [() => _$unwrap(props).class, () => _$unwrap(props).children]);
354
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
+ }
355
399
  var RadioGroupItem2 = cc((props) => {
356
400
  const api = inject(RadioGroupKey2);
401
+ const direction = inject(DirectionKey2, LTR);
402
+ let unregister;
403
+ onUnmounted(() => unregister?.());
357
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
+ },
358
414
  () => _$unwrap(props).id,
359
415
  () => api.value.value === _$unwrap(props).value ? "checked" : "unchecked",
360
416
  () => api.value.value === _$unwrap(props).value,
417
+ () => api.tabStop.value === _$unwrap(props).value ? 0 : -1,
361
418
  () => _$unwrap(props).disabled,
362
419
  () => _$unwrap(props).class,
363
420
  () => {
364
421
  if (!props.disabled)
365
422
  api.setValue(props.value);
366
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
+ },
367
431
  () => _$unwrap(props).children
368
432
  ]);
369
433
  });
@@ -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;
package/dist/controls.js CHANGED
@@ -21,7 +21,8 @@ import {
21
21
  ToggleGroupKey2,
22
22
  ToggleGroupRoot2,
23
23
  ToggleGroupItem2
24
- } from "./chunks/chunk-2sw6berz.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-2sw6berz.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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinwan-ui-core",
3
- "version": "1.1.1",
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",