@zylem/ui 0.8.14 → 0.9.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.
Files changed (57) hide show
  1. package/dist/styles.css +1 -1
  2. package/package.json +1 -1
  3. package/src/components/Accordion/Accordion.tsx +4 -1
  4. package/src/components/BuildLabel/BuildLabel.tsx +0 -1
  5. package/src/components/BuildLabel/mount-build-label.ts +0 -1
  6. package/src/components/Callout/Callout.tsx +1 -1
  7. package/src/components/Card/Card.css.ts +1 -0
  8. package/src/components/Card/Card.tsx +11 -2
  9. package/src/components/Checkbox/Checkbox.tsx +4 -2
  10. package/src/components/ColorPalette/ColorPalette.tsx +1 -1
  11. package/src/components/Console/Console.tsx +3 -0
  12. package/src/components/Dialog/Dialog.css.ts +41 -17
  13. package/src/components/Dialog/Dialog.tsx +134 -53
  14. package/src/components/FrameStrip/FrameStrip.tsx +2 -3
  15. package/src/components/ItemPicker/ItemPicker.tsx +46 -33
  16. package/src/components/MenuButton/MenuButton.tsx +1 -0
  17. package/src/components/Mixer/Knob.tsx +10 -3
  18. package/src/components/Mixer/Mixer.css.ts +19 -0
  19. package/src/components/Mixer/MixerStrip.tsx +29 -8
  20. package/src/components/NumberField/NumberField.tsx +5 -2
  21. package/src/components/Panel/Panel.css.ts +1 -0
  22. package/src/components/Panel/Panel.tsx +9 -1
  23. package/src/components/PianoRoll/PianoRoll.tsx +1 -0
  24. package/src/components/Progress/Progress.tsx +3 -0
  25. package/src/components/Property/Property.css.ts +13 -0
  26. package/src/components/Property/Property.tsx +3 -2
  27. package/src/components/SearchInput/SearchInput.tsx +1 -1
  28. package/src/components/Select/Select.tsx +3 -1
  29. package/src/components/Sidebar/Sidebar.css.ts +1 -0
  30. package/src/components/Sidebar/Sidebar.tsx +23 -5
  31. package/src/components/Slider/Slider.tsx +4 -2
  32. package/src/components/Switch/Switch.tsx +3 -1
  33. package/src/components/Tabs/Tabs.css.ts +0 -5
  34. package/src/components/Tabs/Tabs.tsx +23 -12
  35. package/src/components/TextField/TextField.tsx +4 -0
  36. package/src/components/TimelineLanes/TimelineLanes.css.ts +6 -0
  37. package/src/components/TimelineLanes/TimelineLanes.tsx +52 -4
  38. package/src/components/TimelineLanes/lanes-math.ts +51 -0
  39. package/src/components/TimelineTrack/TimelineTrack.tsx +16 -3
  40. package/src/components/TitleBar/TitleBar.css.ts +1 -0
  41. package/src/components/TitleBar/TitleBar.tsx +7 -1
  42. package/src/components/Toast/Toast.tsx +1 -2
  43. package/src/components/ToolButton/ToolButton.tsx +1 -1
  44. package/src/components/Toolbar/Toolbar.tsx +6 -1
  45. package/src/components/ToolbarButton/ToolbarButton.tsx +9 -2
  46. package/src/components/ToolboxButton/ToolboxButton.tsx +2 -1
  47. package/src/components/TrackHeader/TrackHeader.css.ts +21 -0
  48. package/src/components/TrackHeader/TrackHeader.tsx +37 -12
  49. package/src/components/TransportBar/TransportBar.tsx +4 -1
  50. package/src/components/Waveform/Waveform.css.ts +10 -0
  51. package/src/components/Waveform/Waveform.tsx +59 -27
  52. package/src/components/WindowControls/WindowControls.tsx +4 -4
  53. package/src/components/index.ts +1 -0
  54. package/src/components/shared/Heading.tsx +30 -0
  55. package/src/global/accordion.css.ts +6 -1
  56. package/src/global/common.css.ts +13 -0
  57. package/src/layers/LayerContext.tsx +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zylem/ui",
3
- "version": "0.8.14",
3
+ "version": "0.9.0",
4
4
  "description": "Design tokens, global styles, and Solid components for Zylem, built with vanilla-extract",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,7 @@
1
1
  import { Accordion as KAccordion } from '@kobalte/core';
2
2
  import { For } from 'solid-js';
3
3
  import type { JSX } from 'solid-js';
4
+ import { headingTag, type HeadingLevel } from '../shared/Heading';
4
5
 
5
6
  export interface AccordionItemConfig {
6
7
  id: string;
@@ -16,6 +17,8 @@ export interface AccordionProps {
16
17
  onChange?: (value: string[]) => void;
17
18
  defaultValue?: string[];
18
19
  class?: string;
20
+ /** Heading level of each section header (default 3). */
21
+ headingLevel?: HeadingLevel;
19
22
  /** Extra class applied to each content region (e.g. scroll helpers). */
20
23
  contentClass?: string;
21
24
  /**
@@ -47,7 +50,7 @@ export function Accordion(props: AccordionProps) {
47
50
  <For each={props.items}>
48
51
  {(item) => (
49
52
  <KAccordion.Item value={item.id} class="accordion-item">
50
- <KAccordion.Header class="accordion-header">
53
+ <KAccordion.Header as={headingTag(props.headingLevel ?? 3)} class="accordion-header">
51
54
  <KAccordion.Trigger class="accordion-trigger">
52
55
  {item.title}
53
56
  </KAccordion.Trigger>
@@ -28,7 +28,6 @@ export function BuildLabel(props: BuildLabelProps) {
28
28
  <div
29
29
  id={BUILD_LABEL_ID}
30
30
  class="zylem-build-label"
31
- aria-label="Build version"
32
31
  title={title()}
33
32
  >
34
33
  {label()}
@@ -62,7 +62,6 @@ export function mountBuildLabel(
62
62
  const el = document.createElement('div');
63
63
  el.id = BUILD_LABEL_ID;
64
64
  el.className = 'zylem-build-label';
65
- el.setAttribute('aria-label', 'Build version');
66
65
  el.textContent = formatBuildLabel(resolveBuildVersion(model));
67
66
  const title = packagesTitle(model.packages);
68
67
  if (title) el.title = title;
@@ -26,7 +26,7 @@ export function Callout(props: CalloutProps) {
26
26
  <Show when={props.title}>
27
27
  <span class="zylem-callout-title">{props.title}</span>
28
28
  </Show>
29
- <span class="zylem-callout-message">{props.children}</span>
29
+ <div class="zylem-callout-message">{props.children}</div>
30
30
  </div>
31
31
  );
32
32
  }
@@ -64,6 +64,7 @@ globalStyle('.zylem-card-media > img', {
64
64
  });
65
65
 
66
66
  globalStyle('.zylem-card-title', {
67
+ margin: 0,
67
68
  fontSize: vars.typography.fontSize,
68
69
  fontWeight: 700,
69
70
  color: '#F4FAFF',
@@ -1,13 +1,19 @@
1
1
  import { Show, splitProps } from 'solid-js';
2
2
  import type { JSX } from 'solid-js';
3
+ import { Heading, type HeadingLevel } from '../shared/Heading';
3
4
 
4
5
  export interface CardProps
5
6
  extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title'> {
6
7
  title?: JSX.Element;
8
+ /** Heading level of the title (default 3). */
9
+ headingLevel?: HeadingLevel;
7
10
  description?: JSX.Element;
8
11
  /** Artwork above the title: a thumbnail, preview swatch, or icon well. */
9
12
  media?: JSX.Element;
10
- /** Renders hover glow + pointer cursor and click affordances. */
13
+ /**
14
+ * Renders hover glow + pointer cursor and click affordances. The card
15
+ * becomes a button, so it must not contain other controls.
16
+ */
11
17
  interactive?: boolean;
12
18
  selected?: boolean;
13
19
  /** Rendered after the description: tags, actions, metadata. */
@@ -22,6 +28,7 @@ export interface CardProps
22
28
  export function Card(props: CardProps) {
23
29
  const [local, rest] = splitProps(props, [
24
30
  'title',
31
+ 'headingLevel',
25
32
  'description',
26
33
  'media',
27
34
  'interactive',
@@ -66,7 +73,9 @@ export function Card(props: CardProps) {
66
73
  <div class="zylem-card-media">{local.media}</div>
67
74
  </Show>
68
75
  <Show when={local.title}>
69
- <div class="zylem-card-title">{local.title}</div>
76
+ <Heading level={local.headingLevel ?? 3} class="zylem-card-title">
77
+ {local.title}
78
+ </Heading>
70
79
  </Show>
71
80
  <Show when={local.description}>
72
81
  <div class="zylem-card-description">{local.description}</div>
@@ -4,6 +4,8 @@ import { Show } from 'solid-js';
4
4
 
5
5
  export interface CheckboxProps {
6
6
  label?: string;
7
+ /** Accessible name when there is no visible `label`. */
8
+ 'aria-label'?: string;
7
9
  checked?: boolean;
8
10
  defaultChecked?: boolean;
9
11
  onChange?: (checked: boolean) => void;
@@ -25,10 +27,10 @@ export function Checkbox(props: CheckboxProps) {
25
27
  disabled={props.disabled}
26
28
  name={props.name}
27
29
  >
28
- <KCheckbox.Input class="zylem-checkbox-input" />
30
+ <KCheckbox.Input class="zylem-checkbox-input" aria-label={props['aria-label']} />
29
31
  <KCheckbox.Control class="zylem-checkbox-control">
30
32
  <KCheckbox.Indicator>
31
- <Check class="zylem-checkbox-icon" />
33
+ <Check class="zylem-checkbox-icon" aria-hidden="true" />
32
34
  </KCheckbox.Indicator>
33
35
  </KCheckbox.Control>
34
36
  <Show when={props.label}>
@@ -50,7 +50,7 @@ export interface ColorPaletteProps {
50
50
  export function ColorPalette(props: ColorPaletteProps) {
51
51
  return (
52
52
  <div
53
- role="listbox"
53
+ role="group"
54
54
  aria-label="Palette"
55
55
  class={props.class ? `zylem-palette ${props.class}` : 'zylem-palette'}
56
56
  >
@@ -9,6 +9,8 @@ export interface ConsoleProps {
9
9
  onInput?: (value: string) => void;
10
10
  onClear?: () => void;
11
11
  readOnly?: boolean;
12
+ /** Accessible name of the text field (default "Console"). */
13
+ label?: string;
12
14
  class?: string;
13
15
  /** Extra action buttons rendered next to Clear. */
14
16
  actions?: JSX.Element;
@@ -30,6 +32,7 @@ export function Console(props: ConsoleProps) {
30
32
  <div class="zylem-console-wrapper">
31
33
  <textarea
32
34
  class="zylem-console zylem-scroll"
35
+ aria-label={props.label ?? 'Console'}
33
36
  value={props.value}
34
37
  spellcheck={false}
35
38
  readOnly={props.readOnly || !props.onInput}
@@ -1,7 +1,8 @@
1
1
  import { globalStyle, keyframes } from '@vanilla-extract/css';
2
+ import { LAYER_PORTAL_CLASS } from '../../layers/resolve-mount';
2
3
  import { vars } from '../../theme.css';
3
4
 
4
- const overlayEnter = keyframes({
5
+ const backdropEnter = keyframes({
5
6
  from: { opacity: 0 },
6
7
  to: { opacity: 1 },
7
8
  });
@@ -11,26 +12,56 @@ const contentEnter = keyframes({
11
12
  to: { opacity: 1, transform: 'translateY(0) scale(1)' },
12
13
  });
13
14
 
14
- globalStyle('.zylem-dialog-overlay', {
15
+ // The <dialog> is a transparent sheet over the whole viewport; the card inside
16
+ // is the visible window. Undoes the UA's `dialog:modal` box (centered,
17
+ // max-sized, scrolling) but never sets `display`, which would override the UA
18
+ // rule that hides a closed dialog.
19
+ globalStyle('.zylem-dialog', {
15
20
  position: 'fixed',
16
21
  inset: 0,
17
- zIndex: vars.layers.modal,
22
+ width: '100%',
23
+ height: '100%',
24
+ maxWidth: 'none',
25
+ maxHeight: 'none',
26
+ margin: 0,
27
+ padding: 0,
28
+ border: 0,
29
+ background: 'transparent',
30
+ color: 'inherit',
31
+ font: 'inherit',
32
+ overflow: 'visible',
33
+ outline: 'none',
34
+ });
35
+
36
+ globalStyle('.zylem-dialog::backdrop', {
18
37
  background: 'rgba(5, 10, 18, 0.6)',
19
38
  backdropFilter: 'blur(3px)',
20
- animation: `${overlayEnter} 0.15s ${vars.motion.easeOut}`,
39
+ animation: `${backdropEnter} 0.15s ${vars.motion.easeOut}`,
40
+ });
41
+
42
+ globalStyle('html:has(.zylem-dialog[open])', {
43
+ overflow: 'hidden',
21
44
  });
22
45
 
23
46
  globalStyle('.zylem-dialog-positioner', {
24
- position: 'fixed',
25
- inset: 0,
26
- // One above the backdrop it shares a tier with.
27
- zIndex: `calc(${vars.layers.modal} + 1)`,
28
47
  display: 'flex',
29
48
  alignItems: 'center',
30
49
  justifyContent: 'center',
50
+ boxSizing: 'border-box',
51
+ width: '100%',
52
+ height: '100%',
31
53
  padding: vars.spacing.lg,
32
54
  });
33
55
 
56
+ // The scope's portal container, where overlays opened from the dialog mount.
57
+ // Out of flow so it takes no part in centering the card, at the dialog's
58
+ // origin, which is the viewport's.
59
+ globalStyle(`.zylem-dialog-positioner > .${LAYER_PORTAL_CLASS}`, {
60
+ position: 'absolute',
61
+ top: 0,
62
+ left: 0,
63
+ });
64
+
34
65
  globalStyle('.zylem-dialog-content', {
35
66
  display: 'flex',
36
67
  flexDirection: 'column',
@@ -124,20 +155,13 @@ globalStyle('.zylem-dialog-body', {
124
155
  lineHeight: 1.45,
125
156
  });
126
157
 
127
- // In fullscreen the body and its layer scope become flex columns so content
128
- // can lay out against the dialog's height (and own its scrolling).
158
+ // In fullscreen the body becomes a flex column so content can lay out against
159
+ // the dialog's height (and own its scrolling).
129
160
  globalStyle('.zylem-dialog-content[data-size="fullscreen"] .zylem-dialog-body', {
130
161
  display: 'flex',
131
162
  flexDirection: 'column',
132
163
  });
133
164
 
134
- globalStyle('.zylem-dialog-content[data-size="fullscreen"] .zylem-dialog-scope', {
135
- display: 'flex',
136
- flexDirection: 'column',
137
- flex: '1 1 auto',
138
- minHeight: 0,
139
- });
140
-
141
165
  globalStyle('.zylem-dialog-footer', {
142
166
  flex: 'none',
143
167
  display: 'flex',
@@ -1,7 +1,7 @@
1
- import { Dialog as KDialog } from '@kobalte/core';
2
- import { Show } from 'solid-js';
1
+ import { createUniqueId, onCleanup, onMount, Show } from 'solid-js';
3
2
  import type { JSX } from 'solid-js';
4
- import { createDismissGuard, LayerScope, useLayer } from '../../layers';
3
+ import { Portal } from 'solid-js/web';
4
+ import { LAYER_PORTAL_CLASS, LayerScope, useLayer } from '../../layers';
5
5
  import { Button } from '../Button/Button';
6
6
  import { WindowControls } from '../WindowControls/WindowControls';
7
7
 
@@ -16,7 +16,7 @@ export interface DialogProps {
16
16
  open?: boolean;
17
17
  onOpenChange?: (open: boolean) => void;
18
18
  title?: JSX.Element;
19
- /** Secondary line under the title. */
19
+ /** Secondary line under the title. Describes the dialog unless `aria-describedby` says otherwise. */
20
20
  subtitle?: JSX.Element;
21
21
  /** Content between the titles and the close control, e.g. a search field. */
22
22
  headerExtra?: JSX.Element;
@@ -28,72 +28,147 @@ export interface DialogProps {
28
28
  class?: string;
29
29
  /** Extra class on the body, e.g. to hand scrolling to the content's columns. */
30
30
  bodyClass?: string;
31
+ /** `alertdialog` for an urgent question the user has to answer, such as a confirmation. */
32
+ role?: 'dialog' | 'alertdialog';
33
+ /** Names the dialog when it has no `title`. */
34
+ 'aria-label'?: string;
35
+ /** Id of the element that describes the dialog. */
36
+ 'aria-describedby'?: string;
31
37
  }
32
38
 
33
39
  /**
34
40
  * HyperGlass dialog: glass window with a glossy title bar and jewel close
35
41
  * control, centered over a dimmed backdrop.
36
42
  *
37
- * The body is a {@link LayerScope}, so overlays opened from inside the dialog
38
- * stack against the dialog's own content rather than against the page. A tooltip
39
- * on a dialog field floats over the dialog, but a menu belonging to the page
40
- * behind still wins — which is what keeps a dialog from leaking overlays across
41
- * the rest of the app.
43
+ * A native `<dialog>` opened with `showModal()`: it renders in the top layer,
44
+ * above everything else on the page, which is inert while it is open. Escape
45
+ * and a click outside the card ask to close it, and closing hands focus back
46
+ * to whatever had it before.
47
+ *
48
+ * The card shares a {@link LayerScope} with the scope's portal container, so
49
+ * overlays opened from the dialog render inside the `<dialog>` (outside it they
50
+ * would be inert) but beside the card rather than in it, where the card's
51
+ * clipping would cut them off.
42
52
  */
43
53
  export function Dialog(props: DialogProps) {
44
54
  const layer = useLayer('modal');
45
- const dismiss = createDismissGuard();
46
55
 
47
56
  return (
48
- <KDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
57
+ <>
49
58
  {/*
50
59
  A dialog is controlled and renders no trigger, so it has nothing in the
51
60
  page to resolve its tree from. This marker sits where the dialog was
52
61
  declared, which is enough to find an enclosing shadow root.
53
62
  */}
54
63
  <span ref={layer.anchorRef} style={{ display: 'none' }} aria-hidden="true" />
55
- <Show when={layer.mount()} keyed>
64
+ <Show when={props.open && layer.mount()} keyed>
56
65
  {(mount) => (
57
- <KDialog.Portal mount={mount}>
58
- <KDialog.Overlay class="zylem-dialog-overlay" />
59
- <div class="zylem-dialog-positioner">
60
- <KDialog.Content
61
- ref={dismiss.ref}
62
- class={props.class ? `zylem-dialog-content ${props.class}` : 'zylem-dialog-content'}
63
- data-size={props.size}
64
- onInteractOutside={dismiss.onInteractOutside}
65
- >
66
- <div class="zylem-dialog-header">
67
- <Show when={props.title || props.subtitle}>
68
- <div class="zylem-dialog-titles">
69
- <Show when={props.title}>
70
- <KDialog.Title class="zylem-dialog-title">{props.title}</KDialog.Title>
71
- </Show>
72
- <Show when={props.subtitle}>
73
- <p class="zylem-dialog-subtitle">{props.subtitle}</p>
74
- </Show>
75
- </div>
76
- </Show>
77
- <Show when={props.headerExtra}>
78
- <div class="zylem-dialog-header-extra">{props.headerExtra}</div>
79
- </Show>
80
- <WindowControls onClose={() => props.onOpenChange?.(false)} />
81
- </div>
82
- <KDialog.Description
83
- as="div"
84
- class={props.bodyClass ? `zylem-dialog-body ${props.bodyClass}` : 'zylem-dialog-body'}
85
- >
86
- <LayerScope class="zylem-dialog-scope">{props.children}</LayerScope>
87
- </KDialog.Description>
88
- <Show when={props.footer}>
89
- <div class="zylem-dialog-footer">{props.footer}</div>
90
- </Show>
91
- </KDialog.Content>
92
- </div>
93
- </KDialog.Portal>
66
+ <Portal mount={mount}>
67
+ <DialogWindow {...props} />
68
+ </Portal>
94
69
  )}
95
70
  </Show>
96
- </KDialog.Root>
71
+ </>
72
+ );
73
+ }
74
+
75
+ /** Overlays a click outside the card dismisses on its own, without closing the dialog too. */
76
+ const DISMISSABLE_OVERLAY = '[role="menu"], [role="listbox"], [role="dialog"]';
77
+
78
+ /** The `<dialog>` itself, mounted for as long as the dialog is open. */
79
+ function DialogWindow(props: DialogProps) {
80
+ const titleId = createUniqueId();
81
+ const subtitleId = createUniqueId();
82
+ let dialog!: HTMLDialogElement;
83
+ let disposed = false;
84
+ let pressedBackdrop = false;
85
+
86
+ const close = () => props.onOpenChange?.(false);
87
+ // Outside the card means on the dialog itself or on the positioner filling it.
88
+ const isBackdrop = (target: EventTarget | null | undefined) =>
89
+ target === dialog || target === dialog.firstElementChild;
90
+ const hasOpenOverlay = () =>
91
+ dialog
92
+ .querySelector(`.zylem-dialog-positioner > .${LAYER_PORTAL_CLASS}`)
93
+ ?.querySelector(DISMISSABLE_OVERLAY) != null;
94
+
95
+ onMount(() => {
96
+ // jsdom has no `showModal()`.
97
+ if (typeof dialog.showModal === 'function') dialog.showModal();
98
+ else dialog.setAttribute('open', '');
99
+
100
+ // Kobalte dismisses an open overlay from a capture listener on `document`,
101
+ // so this has to run first, on `window`, to still see it open.
102
+ const onPointerDown = (event: PointerEvent) => {
103
+ pressedBackdrop = isBackdrop(event.composedPath()[0]) && !hasOpenOverlay();
104
+ };
105
+ window.addEventListener('pointerdown', onPointerDown, true);
106
+ onCleanup(() => window.removeEventListener('pointerdown', onPointerDown, true));
107
+ });
108
+
109
+ onCleanup(() => {
110
+ disposed = true;
111
+ // Runs before the portal detaches the element: only a dialog closed while
112
+ // still in the document returns focus to whatever opened it.
113
+ if (dialog.open) dialog.close();
114
+ });
115
+
116
+ return (
117
+ <dialog
118
+ ref={dialog}
119
+ class="zylem-dialog"
120
+ role={props.role}
121
+ aria-labelledby={props.title ? titleId : undefined}
122
+ aria-label={props['aria-label']}
123
+ aria-describedby={props['aria-describedby'] ?? (props.subtitle ? subtitleId : undefined)}
124
+ onCancel={(event) => {
125
+ // `open` stays the source of truth: the host closes the dialog.
126
+ event.preventDefault();
127
+ close();
128
+ }}
129
+ onClose={() => {
130
+ // The browser closed it anyway, e.g. a second Escape it would not let be prevented.
131
+ if (!disposed) close();
132
+ }}
133
+ onClick={(event) => {
134
+ if (pressedBackdrop && isBackdrop(event.target)) close();
135
+ pressedBackdrop = false;
136
+ }}
137
+ >
138
+ <LayerScope class="zylem-dialog-positioner">
139
+ <div
140
+ class={props.class ? `zylem-dialog-content ${props.class}` : 'zylem-dialog-content'}
141
+ data-size={props.size}
142
+ >
143
+ <div class="zylem-dialog-header">
144
+ <Show when={props.title || props.subtitle}>
145
+ <div class="zylem-dialog-titles">
146
+ <Show when={props.title}>
147
+ <h2 id={titleId} class="zylem-dialog-title">
148
+ {props.title}
149
+ </h2>
150
+ </Show>
151
+ <Show when={props.subtitle}>
152
+ <p id={subtitleId} class="zylem-dialog-subtitle">
153
+ {props.subtitle}
154
+ </p>
155
+ </Show>
156
+ </div>
157
+ </Show>
158
+ <Show when={props.headerExtra}>
159
+ <div class="zylem-dialog-header-extra">{props.headerExtra}</div>
160
+ </Show>
161
+ <WindowControls onClose={close} closeLabel="Close dialog" />
162
+ </div>
163
+ <div class={props.bodyClass ? `zylem-dialog-body ${props.bodyClass}` : 'zylem-dialog-body'}>
164
+ {props.children}
165
+ </div>
166
+ <Show when={props.footer}>
167
+ <div class="zylem-dialog-footer">{props.footer}</div>
168
+ </Show>
169
+ </div>
170
+ </LayerScope>
171
+ </dialog>
97
172
  );
98
173
  }
99
174
 
@@ -110,21 +185,27 @@ export interface ConfirmDialogProps {
110
185
 
111
186
  /**
112
187
  * Confirmation dialog built on Dialog — a drop-in replacement for
113
- * `window.confirm()` flows.
188
+ * `window.confirm()` flows. An `alertdialog` described by its message; it
189
+ * opens with focus on Confirm, or on Cancel when the action is destructive.
114
190
  */
115
191
  export function ConfirmDialog(props: ConfirmDialogProps) {
192
+ const messageId = createUniqueId();
193
+
116
194
  return (
117
195
  <Dialog
118
196
  open={props.open}
119
197
  onOpenChange={props.onOpenChange}
198
+ role="alertdialog"
199
+ aria-describedby={messageId}
120
200
  title={props.title ?? 'Confirm'}
121
201
  footer={
122
202
  <>
123
- <Button variant="ghost" onClick={() => props.onOpenChange(false)}>
203
+ <Button variant="ghost" autofocus={props.danger} onClick={() => props.onOpenChange(false)}>
124
204
  {props.cancelLabel ?? 'Cancel'}
125
205
  </Button>
126
206
  <Button
127
207
  variant={props.danger ? 'danger' : 'primary'}
208
+ autofocus={!props.danger}
128
209
  onClick={() => {
129
210
  props.onConfirm();
130
211
  props.onOpenChange(false);
@@ -135,7 +216,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
135
216
  </>
136
217
  }
137
218
  >
138
- {props.message}
219
+ <div id={messageId}>{props.message}</div>
139
220
  </Dialog>
140
221
  );
141
222
  }
@@ -28,7 +28,7 @@ export function FrameStrip(props: FrameStripProps) {
28
28
  <span class="zylem-frame-strip-title">{local.title}</span>
29
29
  </Show>
30
30
  </div>
31
- <div class="zylem-frame-strip-row zylem-scroll" role="listbox" aria-label={local.label ?? 'Frames'}>
31
+ <div class="zylem-frame-strip-row zylem-scroll" role="group" aria-label={local.label ?? 'Frames'}>
32
32
  {local.children}
33
33
  </div>
34
34
  </div>
@@ -51,10 +51,9 @@ export function FrameThumb(props: FrameThumbProps) {
51
51
  return (
52
52
  <button
53
53
  type="button"
54
- role="option"
55
54
  class={props.class ? `zylem-frame-thumb ${props.class}` : 'zylem-frame-thumb'}
56
55
  data-selected={props.selected ? '' : undefined}
57
- aria-selected={props.selected ? 'true' : 'false'}
56
+ aria-pressed={props.selected ? 'true' : 'false'}
58
57
  aria-label={props.label ?? `Frame ${props.index}`}
59
58
  onClick={props.onClick}
60
59
  >
@@ -1,11 +1,14 @@
1
- import { For, Show, createMemo, createSignal, type JSX } from 'solid-js';
1
+ import { For, Show, createMemo, createSignal, createUniqueId, type JSX } from 'solid-js';
2
2
  import { SearchInput } from '../SearchInput/SearchInput';
3
3
  import { filterItems, groupItems, type PickerItem } from './filter-items';
4
4
 
5
5
  export interface ItemPickerProps<T extends PickerItem = PickerItem> {
6
6
  items: T[];
7
7
  onSelect: (item: T) => void;
8
- /** Marked as the current choice. */
8
+ /**
9
+ * Marked as the current choice. Given at all (`null` for none), the items
10
+ * become toggles that expose it as `aria-pressed`.
11
+ */
9
12
  selectedId?: string | null | undefined;
10
13
  /** Hide the search field for a list short enough not to need one. */
11
14
  searchable?: boolean | undefined;
@@ -34,6 +37,9 @@ export function ItemPicker<T extends PickerItem>(props: ItemPickerProps<T>) {
34
37
 
35
38
  const searchable = () => props.searchable !== false;
36
39
  const groups = createMemo(() => groupItems(filterItems(props.items, query())));
40
+ /** Items are toggles only when the caller tracks a choice (`null` is "none yet"). */
41
+ const pressed = (item: T) =>
42
+ props.selectedId === undefined ? undefined : props.selectedId === item.id;
37
43
 
38
44
  return (
39
45
  <div class={props.class ? `zylem-picker ${props.class}` : 'zylem-picker'}>
@@ -68,37 +74,44 @@ export function ItemPicker<T extends PickerItem>(props: ItemPickerProps<T>) {
68
74
  >
69
75
  <div class="zylem-picker__groups zylem-scroll">
70
76
  <For each={groups()}>
71
- {(group) => (
72
- <div class="zylem-picker__group">
73
- <h4 class="zylem-picker__group-title">{group.group}</h4>
74
- <For each={group.items}>
75
- {(item) => (
76
- <button
77
- type="button"
78
- class="zylem-picker__item"
79
- disabled={item.disabled}
80
- data-selected={
81
- props.selectedId === item.id ? '' : undefined
82
- }
83
- title={item.description}
84
- onClick={() => props.onSelect(item)}
85
- >
86
- <Show when={item.icon}>
87
- {(icon) => (
88
- <span
89
- class="zylem-picker__item-glyph"
90
- // Markup from the caller's own registry, in the
91
- // caller's own realm; not a new trust boundary.
92
- innerHTML={icon()}
93
- />
94
- )}
95
- </Show>
96
- <span class="zylem-picker__item-label">{item.label}</span>
97
- </button>
98
- )}
99
- </For>
100
- </div>
101
- )}
77
+ {(group) => {
78
+ const titleId = createUniqueId();
79
+ return (
80
+ <div class="zylem-picker__group" role="group" aria-labelledby={titleId}>
81
+ <div id={titleId} class="zylem-picker__group-title">
82
+ {group.group}
83
+ </div>
84
+ <For each={group.items}>
85
+ {(item) => (
86
+ <button
87
+ type="button"
88
+ class="zylem-picker__item"
89
+ disabled={item.disabled}
90
+ data-selected={
91
+ props.selectedId === item.id ? '' : undefined
92
+ }
93
+ aria-pressed={pressed(item)}
94
+ title={item.description}
95
+ onClick={() => props.onSelect(item)}
96
+ >
97
+ <Show when={item.icon}>
98
+ {(icon) => (
99
+ <span
100
+ class="zylem-picker__item-glyph"
101
+ aria-hidden="true"
102
+ // Markup from the caller's own registry, in the
103
+ // caller's own realm; not a new trust boundary.
104
+ innerHTML={icon()}
105
+ />
106
+ )}
107
+ </Show>
108
+ <span class="zylem-picker__item-label">{item.label}</span>
109
+ </button>
110
+ )}
111
+ </For>
112
+ </div>
113
+ );
114
+ }}
102
115
  </For>
103
116
  </div>
104
117
  </Show>
@@ -140,6 +140,7 @@ export function MenuButton(props: MenuButtonProps) {
140
140
  <KPopover.Content
141
141
  ref={dismiss.ref}
142
142
  class="zylem-menu-button__panel"
143
+ aria-label={props.label}
143
144
  onInteractOutside={dismiss.onInteractOutside}
144
145
  >
145
146
  {props.menu(() => setOpen(false))}