@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
@@ -1,4 +1,4 @@
1
- import { createSignal, Show } from 'solid-js';
1
+ import { createSignal, createUniqueId, Show } from 'solid-js';
2
2
  import { clamp } from '../TimelineLanes/lanes-math';
3
3
 
4
4
  export interface KnobProps {
@@ -11,7 +11,10 @@ export interface KnobProps {
11
11
  step?: number;
12
12
  /** Double-click resets to this. */
13
13
  defaultValue?: number;
14
+ /** Caption under the knob, which also names it. */
14
15
  label?: string;
16
+ /** Accessible name when there is no visible `label`. */
17
+ 'aria-label'?: string;
15
18
  /** Readout under the knob; defaults to the value with `step` precision. */
16
19
  format?: (value: number) => string;
17
20
  /** Draw the arc from the centre (for pan and other ±ranges). */
@@ -48,6 +51,7 @@ export function Knob(props: KnobProps) {
48
51
  const max = () => props.max ?? 1;
49
52
  const step = () => props.step ?? (max() - min()) / 100;
50
53
  const size = () => props.size ?? 36;
54
+ const labelId = createUniqueId();
51
55
  const ratio = () => (max() > min() ? clamp((props.value - min()) / (max() - min()), 0, 1) : 0);
52
56
  const angle = () => START_ANGLE + ratio() * SWEEP;
53
57
  const zeroAngle = () =>
@@ -147,7 +151,8 @@ export function Knob(props: KnobProps) {
147
151
  class="zylem-knob-dial"
148
152
  role="slider"
149
153
  tabIndex={props.disabled ? -1 : 0}
150
- aria-label={props.label}
154
+ aria-labelledby={props.label ? labelId : undefined}
155
+ aria-label={props.label ? undefined : props['aria-label']}
151
156
  aria-valuemin={min()}
152
157
  aria-valuemax={max()}
153
158
  aria-valuenow={props.value}
@@ -180,7 +185,9 @@ export function Knob(props: KnobProps) {
180
185
  </svg>
181
186
  </div>
182
187
  <Show when={props.label}>
183
- <span class="zylem-knob-label">{props.label}</span>
188
+ <span id={labelId} class="zylem-knob-label">
189
+ {props.label}
190
+ </span>
184
191
  </Show>
185
192
  <span class="zylem-knob-value">{readout()}</span>
186
193
  </div>
@@ -236,6 +236,25 @@ globalStyle('.zylem-mixer-name', {
236
236
  textOverflow: 'ellipsis',
237
237
  });
238
238
 
239
+ // A selectable strip's name is a button that still reads as the name.
240
+ globalStyle('button.zylem-mixer-name', {
241
+ appearance: 'none',
242
+ margin: 0,
243
+ padding: 0,
244
+ border: 0,
245
+ background: 'none',
246
+ color: 'inherit',
247
+ fontFamily: 'inherit',
248
+ lineHeight: 'inherit',
249
+ cursor: 'inherit',
250
+ });
251
+
252
+ globalStyle('button.zylem-mixer-name:focus-visible', {
253
+ outline: 'none',
254
+ borderRadius: '3px',
255
+ boxShadow: vars.effects.focusRing,
256
+ });
257
+
239
258
  globalStyle('.zylem-mixer-strip[data-muted] .zylem-mixer-name', {
240
259
  color: vars.colors.textSecondary,
241
260
  });
@@ -26,6 +26,7 @@ export interface MixerStripProps {
26
26
  /** Wider strip with a stronger label; for the master bus. */
27
27
  master?: boolean;
28
28
  selected?: boolean;
29
+ /** Pointer down anywhere on the strip; given, the name is also a button for the keyboard. */
29
30
  onSelect?: () => void;
30
31
  disabled?: boolean;
31
32
  class?: string;
@@ -52,6 +53,7 @@ export function formatPan(value: number): string {
52
53
  export function MixerStrip(props: MixerStripProps) {
53
54
  const min = () => props.minVolume ?? -60;
54
55
  const max = () => props.maxVolume ?? 6;
56
+ const volumeText = () => formatDb(clamp(props.volume, min(), max()), min());
55
57
 
56
58
  return (
57
59
  <div
@@ -61,6 +63,8 @@ export function MixerStrip(props: MixerStripProps) {
61
63
  data-selected={props.selected ? '' : undefined}
62
64
  data-disabled={props.disabled ? '' : undefined}
63
65
  data-muted={props.muted ? '' : undefined}
66
+ role="group"
67
+ aria-label={props.name}
64
68
  onPointerDown={() => props.onSelect?.()}
65
69
  >
66
70
  <Show when={props.pan !== undefined}>
@@ -91,14 +95,13 @@ export function MixerStrip(props: MixerStripProps) {
91
95
  disabled={props.disabled}
92
96
  onChange={(values) => props.onVolumeChange?.(values[0] ?? min())}
93
97
  onChangeEnd={(values) => props.onVolumeCommit?.(values[0] ?? min())}
94
- getValueLabel={(params) => formatDb(params.values[0] ?? min(), min())}
95
98
  >
96
99
  <KSlider.Label class="zylem-fader-sr">{`${props.name} volume`}</KSlider.Label>
97
100
  <KSlider.Track class="zylem-fader-track">
98
101
  <KSlider.Fill class="zylem-fader-fill" />
99
102
  <span class="zylem-fader-zero" aria-hidden="true" />
100
- <KSlider.Thumb class="zylem-fader-thumb">
101
- <KSlider.Input class="zylem-fader-sr" />
103
+ <KSlider.Thumb class="zylem-fader-thumb" aria-valuetext={volumeText()}>
104
+ <KSlider.Input class="zylem-fader-sr" aria-valuetext={volumeText()} />
102
105
  </KSlider.Thumb>
103
106
  </KSlider.Track>
104
107
  </KSlider.Root>
@@ -117,7 +120,7 @@ export function MixerStrip(props: MixerStripProps) {
117
120
  class="zylem-track-toggle"
118
121
  data-kind="mute"
119
122
  aria-pressed={props.muted ? 'true' : 'false'}
120
- aria-label={props.muted ? 'Unmute' : 'Mute'}
123
+ aria-label="Mute"
121
124
  title={props.muted ? 'Unmute' : 'Mute'}
122
125
  disabled={props.disabled}
123
126
  onPointerDown={(event) => event.stopPropagation()}
@@ -132,7 +135,7 @@ export function MixerStrip(props: MixerStripProps) {
132
135
  class="zylem-track-toggle"
133
136
  data-kind="solo"
134
137
  aria-pressed={props.solo ? 'true' : 'false'}
135
- aria-label={props.solo ? 'Unsolo' : 'Solo'}
138
+ aria-label="Solo"
136
139
  title={props.solo ? 'Unsolo' : 'Solo'}
137
140
  disabled={props.disabled}
138
141
  onPointerDown={(event) => event.stopPropagation()}
@@ -142,9 +145,27 @@ export function MixerStrip(props: MixerStripProps) {
142
145
  </button>
143
146
  </Show>
144
147
  </div>
145
- <span class="zylem-mixer-name" title={props.name}>
146
- {props.name}
147
- </span>
148
+ <Show
149
+ when={props.onSelect}
150
+ fallback={
151
+ <span class="zylem-mixer-name" title={props.name}>
152
+ {props.name}
153
+ </span>
154
+ }
155
+ >
156
+ <button
157
+ type="button"
158
+ class="zylem-mixer-name"
159
+ aria-current={props.selected ? 'true' : undefined}
160
+ title={props.name}
161
+ onClick={(event) => {
162
+ // The pointer selected on pointer down; keys and assistive tech click without one.
163
+ if (event.detail === 0) props.onSelect?.();
164
+ }}
165
+ >
166
+ {props.name}
167
+ </button>
168
+ </Show>
148
169
  </div>
149
170
  );
150
171
  }
@@ -3,6 +3,8 @@ import { Show } from 'solid-js';
3
3
 
4
4
  export interface NumberFieldProps {
5
5
  label?: string;
6
+ /** Accessible name when there is no visible `label`. */
7
+ 'aria-label'?: string;
6
8
  error?: string;
7
9
  value?: number;
8
10
  onChange?: (value: number) => void;
@@ -45,19 +47,20 @@ export function NumberField(props: NumberFieldProps) {
45
47
  <div class="zylem-numberfield-group">
46
48
  <KNumberField.Input
47
49
  class="zylem-field zylem-numberfield-input"
50
+ aria-label={props['aria-label']}
48
51
  placeholder={props.placeholder}
49
52
  />
50
53
  <KNumberField.IncrementTrigger
51
54
  class="zylem-numberfield-stepper zylem-numberfield-stepper--up"
52
55
  aria-label="Increment"
53
56
  >
54
- ▲
57
+ <span aria-hidden="true">▲</span>
55
58
  </KNumberField.IncrementTrigger>
56
59
  <KNumberField.DecrementTrigger
57
60
  class="zylem-numberfield-stepper zylem-numberfield-stepper--down"
58
61
  aria-label="Decrement"
59
62
  >
60
- ▼
63
+ <span aria-hidden="true">▼</span>
61
64
  </KNumberField.DecrementTrigger>
62
65
  </div>
63
66
  <Show when={props.error}>
@@ -65,6 +65,7 @@ globalStyle('.zylem-panel-header', {
65
65
  });
66
66
 
67
67
  globalStyle('.zylem-panel-header-title', {
68
+ margin: 0,
68
69
  fontSize: '11px',
69
70
  fontWeight: 700,
70
71
  letterSpacing: '0.09em',
@@ -1,10 +1,13 @@
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 PanelProps
5
6
  extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title'> {
6
7
  /** Optional glossy header title. */
7
8
  title?: JSX.Element;
9
+ /** Heading level of the title (default 2). */
10
+ headingLevel?: HeadingLevel;
8
11
  /** Extra content rendered on the right side of the header. */
9
12
  headerActions?: JSX.Element;
10
13
  /** Use the brighter window-glass treatment. */
@@ -28,6 +31,7 @@ export interface PanelProps
28
31
  export function Panel(props: PanelProps) {
29
32
  const [local, rest] = splitProps(props, [
30
33
  'title',
34
+ 'headingLevel',
31
35
  'headerActions',
32
36
  'window',
33
37
  'flush',
@@ -51,7 +55,11 @@ export function Panel(props: PanelProps) {
51
55
  <div class={classes()} {...rest}>
52
56
  <Show when={local.title || local.headerActions}>
53
57
  <div class="zylem-panel-header">
54
- <span class="zylem-panel-header-title">{local.title}</span>
58
+ <Show when={local.title} fallback={<span class="zylem-panel-header-title" />}>
59
+ <Heading level={local.headingLevel ?? 2} class="zylem-panel-header-title">
60
+ {local.title}
61
+ </Heading>
62
+ </Show>
55
63
  {local.headerActions}
56
64
  </div>
57
65
  </Show>
@@ -219,6 +219,7 @@ export function PianoRoll(props: PianoRollProps) {
219
219
  tabIndex={0}
220
220
  role="application"
221
221
  aria-label="Piano roll"
222
+ aria-disabled={props.disabled ? 'true' : undefined}
222
223
  data-disabled={props.disabled ? '' : undefined}
223
224
  data-dragging={dragging() ? '' : undefined}
224
225
  onKeyDown={handleKeyDown}
@@ -6,6 +6,8 @@ export interface ProgressProps {
6
6
  minValue?: number;
7
7
  maxValue?: number;
8
8
  label?: string;
9
+ /** Accessible name when there is no visible `label`. */
10
+ 'aria-label'?: string;
9
11
  showValue?: boolean;
10
12
  indeterminate?: boolean;
11
13
  class?: string;
@@ -22,6 +24,7 @@ export function Progress(props: ProgressProps) {
22
24
  minValue={props.minValue}
23
25
  maxValue={props.maxValue}
24
26
  indeterminate={props.indeterminate}
27
+ aria-label={props['aria-label']}
25
28
  >
26
29
  <Show when={props.label || props.showValue}>
27
30
  <div class="zylem-progress-header">
@@ -57,13 +57,26 @@ globalStyle('.zylem-property-value', {
57
57
  whiteSpace: 'nowrap',
58
58
  });
59
59
 
60
+ // A button that should read as the value text it stands in for.
60
61
  globalStyle('.zylem-property-value--clickable', {
62
+ appearance: 'none',
63
+ margin: 0,
64
+ padding: 0,
65
+ border: 0,
66
+ background: 'none',
67
+ lineHeight: 'inherit',
68
+ textAlign: 'start',
61
69
  cursor: 'pointer',
62
70
  textDecoration: 'underline',
63
71
  textDecorationStyle: 'dotted',
64
72
  textUnderlineOffset: '2px',
65
73
  });
66
74
 
75
+ globalStyle('.zylem-property-value--clickable:focus-visible', {
76
+ outline: 'none',
77
+ boxShadow: vars.effects.focusRing,
78
+ });
79
+
67
80
  globalStyle('.zylem-property-value--clickable:hover', {
68
81
  textDecorationStyle: 'solid',
69
82
  color: vars.colors.active,
@@ -107,7 +107,8 @@ export function PropertyRow(props: PropertyRowProps) {
107
107
  </span>
108
108
  }
109
109
  >
110
- <span
110
+ <button
111
+ type="button"
111
112
  class={`zylem-property-value zylem-property-value--clickable ${props.valueClass ?? ''}`}
112
113
  onClick={() => {
113
114
  const fullValue = display().fullValue;
@@ -116,7 +117,7 @@ export function PropertyRow(props: PropertyRowProps) {
116
117
  title="Click to reveal full path"
117
118
  >
118
119
  {display().element}
119
- </span>
120
+ </button>
120
121
  </Show>
121
122
  </div>
122
123
  );
@@ -18,7 +18,7 @@ export interface SearchInputProps {
18
18
  export function SearchInput(props: SearchInputProps) {
19
19
  return (
20
20
  <div class={props.class ? `zylem-search ${props.class}` : 'zylem-search'}>
21
- <Search class="zylem-search-icon" />
21
+ <Search class="zylem-search-icon" aria-hidden="true" />
22
22
  <input
23
23
  type="search"
24
24
  ref={props.ref}
@@ -13,6 +13,8 @@ export interface SelectProps {
13
13
  value?: string;
14
14
  onChange?: (value: string | null) => void;
15
15
  label?: string;
16
+ /** Accessible name when there is no visible `label`. */
17
+ 'aria-label'?: string;
16
18
  placeholder?: string;
17
19
  disabled?: boolean;
18
20
  name?: string;
@@ -55,7 +57,7 @@ export function Select(props: SelectProps) {
55
57
  <KSelect.Trigger
56
58
  ref={layer.anchorRef}
57
59
  class="zylem-field zylem-select-trigger"
58
- aria-label={props.label}
60
+ aria-label={props['aria-label']}
59
61
  >
60
62
  <KSelect.Value<SelectOption> class="zylem-select-value">
61
63
  {(state) => state.selectedOption()?.label}
@@ -34,6 +34,7 @@ globalStyle('.sidebar-section', {
34
34
  });
35
35
 
36
36
  globalStyle('.sidebar-section-title', {
37
+ margin: 0,
37
38
  padding: `${vars.spacing.sm} ${vars.spacing.md} ${vars.spacing.xs}`,
38
39
  fontSize: '10px',
39
40
  fontWeight: 700,
@@ -1,8 +1,15 @@
1
- import { Show, splitProps } from 'solid-js';
1
+ import { createUniqueId, Show, splitProps } from 'solid-js';
2
2
  import type { JSX } from 'solid-js';
3
+ import { Dynamic } from 'solid-js/web';
4
+ import { Heading, type HeadingLevel } from '../shared/Heading';
3
5
 
4
6
  export interface SidebarProps extends JSX.HTMLAttributes<HTMLElement> {
5
7
  children?: JSX.Element;
8
+ /**
9
+ * Landmark element: `aside` (the default) for a rail beside the main
10
+ * content, `nav` when the rail is the page's navigation.
11
+ */
12
+ as?: 'aside' | 'nav';
6
13
  /**
7
14
  * Drop the glass surface and trailing border so the list sits inside a
8
15
  * panel that already draws them (an inspector rail, a dialog column).
@@ -16,34 +23,43 @@ export interface SidebarProps extends JSX.HTMLAttributes<HTMLElement> {
16
23
  * and `SidebarItem`.
17
24
  */
18
25
  export function Sidebar(props: SidebarProps) {
19
- const [local, rest] = splitProps(props, ['children', 'class', 'borderless']);
26
+ const [local, rest] = splitProps(props, ['children', 'class', 'borderless', 'as']);
20
27
  return (
21
- <aside
28
+ <Dynamic
29
+ component={local.as ?? 'aside'}
22
30
  class={['sidebar', 'zylem-scroll', local.borderless ? 'sidebar--borderless' : '', local.class ?? '']
23
31
  .filter(Boolean)
24
32
  .join(' ')}
25
33
  {...rest}
26
34
  >
27
35
  {local.children}
28
- </aside>
36
+ </Dynamic>
29
37
  );
30
38
  }
31
39
 
32
40
  export interface SidebarSectionProps {
33
41
  title?: JSX.Element;
42
+ /** Heading level of the title (default 2). */
43
+ headingLevel?: HeadingLevel;
34
44
  children?: JSX.Element;
35
45
  class?: string;
36
46
  }
37
47
 
48
+ /** A titled group of items; the title names the group for assistive tech. */
38
49
  export function SidebarSection(props: SidebarSectionProps) {
50
+ const titleId = createUniqueId();
39
51
  return (
40
52
  <div
41
53
  class={
42
54
  props.class ? `sidebar-section ${props.class}` : 'sidebar-section'
43
55
  }
56
+ role={props.title ? 'group' : undefined}
57
+ aria-labelledby={props.title ? titleId : undefined}
44
58
  >
45
59
  <Show when={props.title}>
46
- <div class="sidebar-section-title">{props.title}</div>
60
+ <Heading level={props.headingLevel ?? 2} id={titleId} class="sidebar-section-title">
61
+ {props.title}
62
+ </Heading>
47
63
  </Show>
48
64
  {props.children}
49
65
  </div>
@@ -52,6 +68,7 @@ export function SidebarSection(props: SidebarSectionProps) {
52
68
 
53
69
  export interface SidebarItemProps
54
70
  extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
71
+ /** The current item; exposed as `aria-current="true"`. */
55
72
  active?: boolean;
56
73
  children?: JSX.Element;
57
74
  }
@@ -63,6 +80,7 @@ export function SidebarItem(props: SidebarItemProps) {
63
80
  type="button"
64
81
  class={local.class ? `sidebar-item ${local.class}` : 'sidebar-item'}
65
82
  data-active={local.active ? '' : undefined}
83
+ aria-current={local.active ? 'true' : undefined}
66
84
  {...rest}
67
85
  >
68
86
  {local.children}
@@ -3,6 +3,8 @@ import { Show } from 'solid-js';
3
3
 
4
4
  export interface SliderProps {
5
5
  label?: string;
6
+ /** Accessible name when there is no visible `label`. */
7
+ 'aria-label'?: string;
6
8
  value?: number;
7
9
  onChange?: (value: number) => void;
8
10
  /**
@@ -48,8 +50,8 @@ export function Slider(props: SliderProps) {
48
50
  </Show>
49
51
  <KSlider.Track class="zylem-slider-track">
50
52
  <KSlider.Fill class="zylem-slider-fill" />
51
- <KSlider.Thumb class="zylem-slider-thumb">
52
- <KSlider.Input class="zylem-slider-thumb-input" />
53
+ <KSlider.Thumb class="zylem-slider-thumb" aria-label={props['aria-label']}>
54
+ <KSlider.Input class="zylem-slider-thumb-input" aria-label={props['aria-label']} />
53
55
  </KSlider.Thumb>
54
56
  </KSlider.Track>
55
57
  </KSlider.Root>
@@ -3,6 +3,8 @@ import { Show } from 'solid-js';
3
3
 
4
4
  export interface SwitchProps {
5
5
  label?: string;
6
+ /** Accessible name when there is no visible `label`. */
7
+ 'aria-label'?: string;
6
8
  checked?: boolean;
7
9
  defaultChecked?: boolean;
8
10
  onChange?: (checked: boolean) => void;
@@ -24,7 +26,7 @@ export function Switch(props: SwitchProps) {
24
26
  disabled={props.disabled}
25
27
  name={props.name}
26
28
  >
27
- <KSwitch.Input class="zylem-switch-input" />
29
+ <KSwitch.Input class="zylem-switch-input" aria-label={props['aria-label']} />
28
30
  <KSwitch.Control class="zylem-switch-control">
29
31
  <KSwitch.Thumb class="zylem-switch-thumb" />
30
32
  </KSwitch.Control>
@@ -102,11 +102,6 @@ globalStyle('.zylem-tabs-close:hover', {
102
102
  color: '#FFF4F2',
103
103
  });
104
104
 
105
- globalStyle('.zylem-tabs-close:focus-visible', {
106
- outline: 'none',
107
- boxShadow: vars.effects.focusRing,
108
- });
109
-
110
105
  globalStyle('.zylem-tabs-content', {
111
106
  flex: 1,
112
107
  minHeight: 0,
@@ -46,12 +46,12 @@ export interface TabsTriggerProps {
46
46
  disabled?: boolean;
47
47
  class?: string;
48
48
  /**
49
- * Document-style tabs: shows a close affordance after the label. It is a
50
- * `role="button"` span (a button may not nest in the trigger button) and
51
- * closing does not select the tab.
49
+ * Document-style tabs: Delete (Backspace on a Mac keyboard) closes the
50
+ * focused tab, and a close affordance after the label does the same for the
51
+ * pointer. Closing does not select the tab.
52
52
  */
53
53
  onClose?: () => void;
54
- /** Accessible name of the close affordance; defaults to `Close tab`. */
54
+ /** Tooltip of the close affordance; defaults to `Close tab`. */
55
55
  closeLabel?: string;
56
56
  /** Unsaved-changes marker before the close affordance. */
57
57
  dirty?: boolean;
@@ -59,10 +59,22 @@ export interface TabsTriggerProps {
59
59
 
60
60
  export function TabsTrigger(props: TabsTriggerProps) {
61
61
  const close = (event: Event) => {
62
+ // Stopped so a page-level Delete shortcut does not also act on the key.
62
63
  event.stopPropagation();
63
64
  event.preventDefault();
64
65
  props.onClose?.();
65
66
  };
67
+ const closeFromKeyboard = (event: KeyboardEvent & { currentTarget: HTMLButtonElement }) => {
68
+ if (!props.onClose || (event.key !== 'Delete' && event.key !== 'Backspace')) return;
69
+ const trigger = event.currentTarget;
70
+ const list = trigger.closest('[role="tablist"]');
71
+ close(event);
72
+ // A closed tab usually leaves the document, and focus with it.
73
+ queueMicrotask(() => {
74
+ if (trigger.isConnected) return;
75
+ list?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')?.focus();
76
+ });
77
+ };
66
78
  return (
67
79
  <KTabs.Trigger
68
80
  class={
@@ -70,25 +82,24 @@ export function TabsTrigger(props: TabsTriggerProps) {
70
82
  }
71
83
  value={props.value}
72
84
  disabled={props.disabled}
85
+ aria-keyshortcuts={props.onClose ? 'Delete Backspace' : undefined}
86
+ onKeyDown={closeFromKeyboard}
73
87
  >
74
88
  <span class="zylem-tabs-trigger-label">{props.children}</span>
75
89
  <Show when={props.dirty}>
76
- <span class="zylem-tabs-dirty" aria-label="Unsaved changes" title="Unsaved changes" />
90
+ <span class="zylem-tabs-dirty" aria-hidden="true" title="Unsaved changes" />
91
+ <span class="zylem-visually-hidden">(unsaved changes)</span>
77
92
  </Show>
78
93
  <Show when={props.onClose}>
94
+ {/* Pointer only: a button may not nest in the trigger button, and the key covers it. */}
79
95
  <span
80
96
  class="zylem-tabs-close"
81
- role="button"
82
- tabIndex={0}
83
- aria-label={props.closeLabel ?? 'Close tab'}
97
+ aria-hidden="true"
84
98
  title={props.closeLabel ?? 'Close tab'}
85
99
  onClick={close}
86
100
  onPointerDown={(event) => event.stopPropagation()}
87
- onKeyDown={(event) => {
88
- if (event.key === 'Enter' || event.key === ' ') close(event);
89
- }}
90
101
  >
91
- <X aria-hidden="true" />
102
+ <X />
92
103
  </span>
93
104
  </Show>
94
105
  </KTabs.Trigger>
@@ -4,6 +4,8 @@ import type { JSX } from 'solid-js';
4
4
 
5
5
  export interface TextFieldProps {
6
6
  label?: string;
7
+ /** Accessible name when there is no visible `label`. */
8
+ 'aria-label'?: string;
7
9
  error?: string;
8
10
  placeholder?: string;
9
11
  value?: string;
@@ -92,6 +94,7 @@ export function TextField(props: TextFieldProps) {
92
94
  <KTextField.Input
93
95
  class="zylem-field zylem-textfield-input"
94
96
  type={props.type ?? 'text'}
97
+ aria-label={props['aria-label']}
95
98
  placeholder={props.placeholder}
96
99
  autofocus={props.autofocus}
97
100
  onInput={props.onInput as JSX.EventHandlerUnion<HTMLInputElement, InputEvent>}
@@ -102,6 +105,7 @@ export function TextField(props: TextFieldProps) {
102
105
  >
103
106
  <KTextField.TextArea
104
107
  class="zylem-field zylem-textfield-textarea"
108
+ aria-label={props['aria-label']}
105
109
  placeholder={props.placeholder}
106
110
  rows={props.rows}
107
111
  autofocus={props.autofocus}
@@ -66,6 +66,12 @@ globalStyle('.zylem-lanes-ruler', {
66
66
  cursor: 'pointer',
67
67
  });
68
68
 
69
+ // Inset: the scrolling lanes would clip a ring drawn outside the sticky ruler.
70
+ globalStyle('.zylem-lanes-ruler:focus-visible', {
71
+ outline: 'none',
72
+ boxShadow: 'inset 0 0 0 2px rgba(97, 166, 232, 0.6)',
73
+ });
74
+
69
75
  globalStyle('.zylem-lanes-range', {
70
76
  position: 'absolute',
71
77
  top: 0,