@nqmcreative/ui 0.4.1 → 0.4.2

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 (120) hide show
  1. package/README.md +1 -1
  2. package/dist/core/index.d.ts +1 -1
  3. package/dist/core/index.js +1 -1
  4. package/dist/core/tones.d.ts +10 -0
  5. package/dist/core/tones.js +19 -0
  6. package/dist/core/trigger.d.ts +8 -0
  7. package/dist/core/trigger.js +11 -0
  8. package/dist/styles/matte/Alert.svelte +1 -1
  9. package/dist/styles/matte/Avatar.svelte +16 -3
  10. package/dist/styles/matte/Button.svelte +6 -2
  11. package/dist/styles/matte/Checkbox.svelte +49 -30
  12. package/dist/styles/matte/CheckboxGroup.svelte +15 -4
  13. package/dist/styles/matte/Combobox.svelte +11 -2
  14. package/dist/styles/matte/ConfirmDialog.svelte +10 -2
  15. package/dist/styles/matte/DatePicker.svelte +9 -2
  16. package/dist/styles/matte/Divider.svelte +1 -1
  17. package/dist/styles/matte/Drawer.svelte +10 -2
  18. package/dist/styles/matte/Field.svelte +59 -3
  19. package/dist/styles/matte/Footer.svelte +14 -3
  20. package/dist/styles/matte/Input.svelte +9 -2
  21. package/dist/styles/matte/Label.svelte +3 -1
  22. package/dist/styles/matte/Link.svelte +13 -2
  23. package/dist/styles/matte/MenuItem.svelte +11 -4
  24. package/dist/styles/matte/Modal.svelte +10 -2
  25. package/dist/styles/matte/Navbar.svelte +2 -2
  26. package/dist/styles/matte/Radio.svelte +41 -22
  27. package/dist/styles/matte/RadioGroup.svelte +15 -4
  28. package/dist/styles/matte/Select.svelte +4 -2
  29. package/dist/styles/matte/Skeleton.svelte +2 -1
  30. package/dist/styles/matte/Stat.svelte +27 -3
  31. package/dist/styles/matte/Switch.svelte +6 -1
  32. package/dist/styles/matte/Tabs.svelte +1 -1
  33. package/dist/styles/matte/Textarea.svelte +4 -2
  34. package/dist/styles/matte/Toaster.svelte +21 -3
  35. package/dist/styles/matte/Tooltip.svelte +8 -1
  36. package/dist/styles/matte/index.d.ts +1 -1
  37. package/dist/styles/matte/index.js +1 -1
  38. package/dist/styles/matte/theme.css +21 -0
  39. package/dist/styles/paper/Alert.svelte +2 -2
  40. package/dist/styles/paper/Avatar.svelte +14 -3
  41. package/dist/styles/paper/Button.svelte +5 -1
  42. package/dist/styles/paper/Card.svelte +1 -1
  43. package/dist/styles/paper/Checkbox.svelte +55 -36
  44. package/dist/styles/paper/CheckboxGroup.svelte +15 -4
  45. package/dist/styles/paper/Combobox.svelte +11 -2
  46. package/dist/styles/paper/ConfirmDialog.svelte +8 -2
  47. package/dist/styles/paper/DatePicker.svelte +9 -2
  48. package/dist/styles/paper/Divider.svelte +1 -1
  49. package/dist/styles/paper/Drawer.svelte +8 -2
  50. package/dist/styles/paper/Field.svelte +59 -3
  51. package/dist/styles/paper/Footer.svelte +14 -3
  52. package/dist/styles/paper/Input.svelte +9 -2
  53. package/dist/styles/paper/Label.svelte +3 -1
  54. package/dist/styles/paper/Link.svelte +13 -2
  55. package/dist/styles/paper/MenuItem.svelte +11 -4
  56. package/dist/styles/paper/Modal.svelte +8 -2
  57. package/dist/styles/paper/Navbar.svelte +2 -2
  58. package/dist/styles/paper/Radio.svelte +41 -22
  59. package/dist/styles/paper/RadioGroup.svelte +15 -4
  60. package/dist/styles/paper/SegmentedControl.svelte +2 -2
  61. package/dist/styles/paper/Select.svelte +4 -2
  62. package/dist/styles/paper/Skeleton.svelte +2 -1
  63. package/dist/styles/paper/Stat.svelte +24 -2
  64. package/dist/styles/paper/Switch.svelte +6 -1
  65. package/dist/styles/paper/Tabs.svelte +1 -1
  66. package/dist/styles/paper/Textarea.svelte +4 -2
  67. package/dist/styles/paper/Toaster.svelte +19 -3
  68. package/dist/styles/paper/Tooltip.svelte +8 -1
  69. package/dist/styles/paper/index.d.ts +1 -1
  70. package/dist/styles/paper/index.js +1 -1
  71. package/dist/styles/paper/theme.css +21 -0
  72. package/dist/styles/sprout/Alert.svelte +3 -3
  73. package/dist/styles/sprout/Avatar.svelte +14 -3
  74. package/dist/styles/sprout/Breadcrumb.svelte +1 -1
  75. package/dist/styles/sprout/Button.svelte +8 -4
  76. package/dist/styles/sprout/Calendar.svelte +1 -1
  77. package/dist/styles/sprout/Card.svelte +2 -2
  78. package/dist/styles/sprout/Checkbox.svelte +55 -36
  79. package/dist/styles/sprout/CheckboxGroup.svelte +15 -4
  80. package/dist/styles/sprout/ColorInput.svelte +1 -1
  81. package/dist/styles/sprout/Combobox.svelte +12 -3
  82. package/dist/styles/sprout/ConfirmDialog.svelte +8 -2
  83. package/dist/styles/sprout/DatePicker.svelte +11 -4
  84. package/dist/styles/sprout/Divider.svelte +1 -1
  85. package/dist/styles/sprout/Drawer.svelte +8 -2
  86. package/dist/styles/sprout/Dropzone.svelte +1 -1
  87. package/dist/styles/sprout/Field.svelte +59 -3
  88. package/dist/styles/sprout/FileInput.svelte +3 -3
  89. package/dist/styles/sprout/Footer.svelte +14 -3
  90. package/dist/styles/sprout/Input.svelte +9 -2
  91. package/dist/styles/sprout/Label.svelte +3 -1
  92. package/dist/styles/sprout/Link.svelte +13 -2
  93. package/dist/styles/sprout/LogoCloud.svelte +1 -1
  94. package/dist/styles/sprout/MenuItem.svelte +11 -4
  95. package/dist/styles/sprout/Modal.svelte +8 -2
  96. package/dist/styles/sprout/MultiSelect.svelte +3 -3
  97. package/dist/styles/sprout/Navbar.svelte +2 -2
  98. package/dist/styles/sprout/Radio.svelte +41 -22
  99. package/dist/styles/sprout/RadioGroup.svelte +15 -4
  100. package/dist/styles/sprout/SearchInput.svelte +1 -1
  101. package/dist/styles/sprout/SegmentedControl.svelte +2 -2
  102. package/dist/styles/sprout/Select.svelte +4 -2
  103. package/dist/styles/sprout/Sidebar.svelte +2 -1
  104. package/dist/styles/sprout/Skeleton.svelte +2 -1
  105. package/dist/styles/sprout/Stat.svelte +24 -2
  106. package/dist/styles/sprout/Switch.svelte +6 -1
  107. package/dist/styles/sprout/Table.svelte +1 -1
  108. package/dist/styles/sprout/Tabs.svelte +1 -1
  109. package/dist/styles/sprout/TagsInput.svelte +1 -1
  110. package/dist/styles/sprout/Textarea.svelte +4 -2
  111. package/dist/styles/sprout/TimeInput.svelte +1 -1
  112. package/dist/styles/sprout/Toaster.svelte +22 -6
  113. package/dist/styles/sprout/Tooltip.svelte +8 -1
  114. package/dist/styles/sprout/index.d.ts +2 -2
  115. package/dist/styles/sprout/index.js +2 -2
  116. package/dist/styles/sprout/lift.d.ts +14 -0
  117. package/dist/styles/sprout/lift.js +23 -0
  118. package/dist/styles/sprout/theme.css +50 -24
  119. package/package.json +1 -1
  120. package/registry.json +4 -2
@@ -3,7 +3,12 @@
3
3
  import { clickOutside, portal } from '../../core/actions/dismissable.js';
4
4
  import { dateHint } from '../../core/calendar.js';
5
5
  import { formatISO, parseFormatted, type DateFormat } from '../../core/date.js';
6
- import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
6
+ import {
7
+ focusWithinRing,
8
+ toneFocusWithinBorder,
9
+ toneFocusWithinRing,
10
+ type Tone
11
+ } from '../../core/tones.js';
7
12
  import Calendar from './Calendar.svelte';
8
13
  import { edge, soft } from './lift.js';
9
14
 
@@ -95,7 +100,9 @@
95
100
 
96
101
  <div
97
102
  bind:this={wrapper}
98
- class="inline-flex w-full items-center gap-1 rounded-xl border bg-bg pr-1 pl-3 {edge} transition-colors duration-150 ease-brand-out
103
+ class="inline-flex w-full items-center gap-1 rounded-xl border bg-bg pr-1 pl-3 {edge} transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
104
+ tone
105
+ ]}
99
106
  {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
100
107
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
101
108
  >
@@ -134,7 +141,7 @@
134
141
  inputEl?.focus();
135
142
  }}
136
143
  aria-label="Clear selection"
137
- class="shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
144
+ class="shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
138
145
  >
139
146
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
140
147
  <path
@@ -153,7 +160,7 @@
153
160
  onclick={() => (open = !open)}
154
161
  aria-label="Select a date"
155
162
  aria-expanded={open}
156
- class="shrink-0 rounded p-1.5 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
163
+ class="shrink-0 rounded-xl p-1.5 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
157
164
  >
158
165
  <svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
159
166
  <rect x="3" y="4.5" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.4" />
@@ -20,7 +20,7 @@
20
20
  {...rest}
21
21
  ></div>
22
22
  {:else if label}
23
- <div class="flex items-center gap-3 {className}" {...rest}>
23
+ <div role="separator" class="flex items-center gap-3 {className}" {...rest}>
24
24
  <span class="h-px flex-1 bg-hairline"></span>
25
25
  <span class="font-sans text-xs font-medium text-text-muted">{label}</span>
26
26
  <span class="h-px flex-1 bg-hairline"></span>
@@ -39,6 +39,8 @@
39
39
  ...rest
40
40
  }: Props = $props();
41
41
 
42
+ const uid = $props.id();
43
+
42
44
  let dialog: HTMLDialogElement | null = $state(null);
43
45
 
44
46
  const horizontal = $derived(side === 'left' || side === 'right');
@@ -81,6 +83,8 @@
81
83
 
82
84
  <dialog
83
85
  bind:this={dialog}
86
+ aria-labelledby={title ? `${uid}-title` : undefined}
87
+ aria-describedby={description ? `${uid}-description` : undefined}
84
88
  onclose={close}
85
89
  onclick={(event) => {
86
90
  if (dismissible && isBackdropClick(event, dialog)) close();
@@ -98,10 +102,12 @@
98
102
  {#if icon}<span class={iconLg}>{@render icon()}</span>{/if}
99
103
  <div class="flex min-w-0 flex-1 flex-col gap-1">
100
104
  {#if title}
101
- <h2 class="font-heading text-base font-semibold text-text">{title}</h2>
105
+ <h2 id="{uid}-title" class="font-heading text-base font-semibold text-text">{title}</h2>
102
106
  {/if}
103
107
  {#if description}
104
- <p class="font-sans text-[13px] text-text-muted">{description}</p>
108
+ <p id="{uid}-description" class="font-sans text-[13px] text-text-muted">
109
+ {description}
110
+ </p>
105
111
  {/if}
106
112
  </div>
107
113
  {#if dismissible}
@@ -190,7 +190,7 @@
190
190
  type="button"
191
191
  onclick={() => remove(i)}
192
192
  aria-label="Remove {file.name}"
193
- class="-m-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
193
+ class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
194
194
  >
195
195
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
196
196
  <path
@@ -24,9 +24,65 @@
24
24
  children,
25
25
  ...rest
26
26
  }: Props = $props();
27
+
28
+ const uid = $props.id();
29
+ const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
30
+
31
+ interface Described {
32
+ for?: string;
33
+ messageId?: string;
34
+ required: boolean;
35
+ invalid: boolean;
36
+ }
37
+
38
+ /**
39
+ * A hint nobody can hear is not a hint. `aria-describedby` has to sit on the
40
+ * control, and the control arrives through `children` — out of reach of this
41
+ * template — so the field finds it once the DOM exists and keeps the three
42
+ * attributes in step itself. It touches only what it set: an `aria-invalid`
43
+ * the control already carries from its own `invalid` prop stays that
44
+ * control's business.
45
+ */
46
+ function describes(node: HTMLElement, state: Described) {
47
+ let ourId: string | undefined;
48
+ let ownsInvalid = false;
49
+
50
+ const apply = (next: Described) => {
51
+ const control =
52
+ (next.for ? node.querySelector<HTMLElement>(`#${CSS.escape(next.for)}`) : null) ??
53
+ node.querySelector<HTMLElement>('input, select, textarea');
54
+ if (!control) return;
55
+
56
+ const ids = (control.getAttribute('aria-describedby') ?? '')
57
+ .split(/\s+/)
58
+ .filter((id) => id && id !== ourId);
59
+ if (next.messageId) ids.push(next.messageId);
60
+ if (ids.length) control.setAttribute('aria-describedby', ids.join(' '));
61
+ else control.removeAttribute('aria-describedby');
62
+ ourId = next.messageId;
63
+
64
+ if (next.required) control.setAttribute('aria-required', 'true');
65
+ else control.removeAttribute('aria-required');
66
+
67
+ if (next.invalid) {
68
+ ownsInvalid ||= !control.hasAttribute('aria-invalid');
69
+ control.setAttribute('aria-invalid', 'true');
70
+ } else if (ownsInvalid) {
71
+ control.removeAttribute('aria-invalid');
72
+ ownsInvalid = false;
73
+ }
74
+ };
75
+
76
+ apply(state);
77
+ return { update: apply };
78
+ }
27
79
  </script>
28
80
 
29
- <div class="flex w-full flex-col gap-1.5 {className}" {...rest}>
81
+ <div
82
+ use:describes={{ for: htmlFor, messageId, required, invalid: Boolean(error) }}
83
+ class="flex w-full flex-col gap-1.5 {className}"
84
+ {...rest}
85
+ >
30
86
  {#if label}
31
87
  <label for={htmlFor} class="font-sans text-sm font-medium text-text">
32
88
  {label}
@@ -35,8 +91,8 @@
35
91
  {/if}
36
92
  {@render children()}
37
93
  {#if error}
38
- <p class="font-sans text-[13px] text-danger">{error}</p>
94
+ <p id="{uid}-error" role="alert" class="font-sans text-[13px] text-danger">{error}</p>
39
95
  {:else if hint}
40
- <p class="font-sans text-[13px] text-text-muted">{hint}</p>
96
+ <p id="{uid}-hint" class="font-sans text-[13px] text-text-muted">{hint}</p>
41
97
  {/if}
42
98
  </div>
@@ -119,7 +119,7 @@
119
119
  type="button"
120
120
  {disabled}
121
121
  onclick={() => input?.click()}
122
- class="shrink-0 rounded-lg border border-hairline-strong bg-bg-alt font-sans font-medium text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current
122
+ class="shrink-0 rounded-xl border border-hairline-strong bg-bg-alt font-sans font-medium text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current
123
123
  {buttons[size]}"
124
124
  >
125
125
  {label ?? 'Choose file'}
@@ -136,7 +136,7 @@
136
136
  type="button"
137
137
  onclick={clear}
138
138
  aria-label="Remove"
139
- class="mr-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
139
+ class="mr-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
140
140
  >
141
141
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
142
142
  <path
@@ -184,7 +184,7 @@
184
184
  type="button"
185
185
  onclick={() => (files = files.filter((_, at) => at !== i))}
186
186
  aria-label="Remove {file.name}"
187
- class="-m-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
187
+ class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
188
188
  >
189
189
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
190
190
  <path
@@ -59,15 +59,26 @@
59
59
  <a
60
60
  href={item.href}
61
61
  target={item.external ? '_blank' : undefined}
62
- rel={item.external ? 'noreferrer' : undefined}
62
+ rel={item.external ? 'noopener noreferrer' : undefined}
63
63
  class="inline-flex items-center gap-2 font-sans text-[13px] text-text-muted transition-colors duration-150 ease-brand-out {toneHoverText[
64
64
  tone
65
65
  ]}"
66
66
  >
67
67
  {#if item.icon}<span class={iconSm}>{@render item.icon()}</span
68
68
  >{/if}{item.label}
69
- {#if item.external}<span aria-hidden="true" class="text-[0.85em]">↗</span
70
- >{/if}
69
+ {#if item.external}<svg
70
+ class="inline-block size-[0.85em] align-[-0.05em]"
71
+ viewBox="0 0 14 14"
72
+ fill="none"
73
+ aria-hidden="true"
74
+ ><path
75
+ d="M5.5 2.5h6v6M11.5 2.5 3.5 10.5"
76
+ stroke="currentColor"
77
+ stroke-width="1.5"
78
+ stroke-linecap="round"
79
+ stroke-linejoin="round"
80
+ /></svg
81
+ ><span class="sr-only">(opens in a new tab)</span>{/if}
71
82
  </a>
72
83
  </li>
73
84
  {/each}
@@ -1,7 +1,12 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLInputAttributes } from 'svelte/elements';
4
- import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
4
+ import {
5
+ focusWithinRing,
6
+ toneFocusWithinBorder,
7
+ toneFocusWithinRing,
8
+ type Tone
9
+ } from '../../core/tones.js';
5
10
  import { edge } from './lift.js';
6
11
 
7
12
  export type InputSize = 'sm' | 'md' | 'lg';
@@ -38,7 +43,9 @@
38
43
  </script>
39
44
 
40
45
  <span
41
- class="inline-flex w-full items-center gap-2 rounded-xl border bg-bg px-3 {edge} transition-colors duration-150 ease-brand-out
46
+ class="inline-flex w-full items-center gap-2 rounded-xl border bg-bg px-3 {edge} transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
47
+ tone
48
+ ]}
42
49
  {sizes[size]}
43
50
  {invalid
44
51
  ? 'border-danger focus-within:border-danger'
@@ -12,5 +12,7 @@
12
12
 
13
13
  <label class="font-sans text-sm font-medium text-text {className}" {...rest}>
14
14
  {@render children()}
15
- {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
15
+ {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span><span class="sr-only"
16
+ >(required)</span
17
+ >{/if}
16
18
  </label>
@@ -40,7 +40,7 @@
40
40
 
41
41
  <a
42
42
  target={external ? '_blank' : undefined}
43
- rel={external ? 'noreferrer' : undefined}
43
+ rel={external ? 'noopener noreferrer' : undefined}
44
44
  class="inline-flex items-center gap-1 rounded-md font-sans transition-colors duration-150 ease-brand-out
45
45
  {muted ? `text-text-secondary ${toneHoverText[tone]}` : toneText[tone]}
46
46
  {underlines[underline]} {focusRing} {toneRing[tone]} {className}"
@@ -49,5 +49,16 @@
49
49
  {#if icon}<span class={iconSm}>{@render icon()}</span>{/if}
50
50
  {@render children()}
51
51
  {#if iconEnd}<span class={iconSm}>{@render iconEnd()}</span>{/if}
52
- {#if external}<span aria-hidden="true" class="text-[0.85em]">↗</span>{/if}
52
+ {#if external}
53
+ <svg class="size-[0.85em] shrink-0" viewBox="0 0 14 14" fill="none" aria-hidden="true">
54
+ <path
55
+ d="M5.5 2.5h6v6M11.5 2.5 3.5 10.5"
56
+ stroke="currentColor"
57
+ stroke-width="1.5"
58
+ stroke-linecap="round"
59
+ stroke-linejoin="round"
60
+ />
61
+ </svg>
62
+ <span class="sr-only">(opens in a new tab)</span>
63
+ {/if}
53
64
  </a>
@@ -42,7 +42,7 @@
42
42
  <svelte:element
43
43
  this={logo.href ? 'a' : 'div'}
44
44
  href={logo.href}
45
- class="flex items-center rounded transition-all duration-200 ease-brand-out {muted
45
+ class="flex items-center rounded-md transition-all duration-200 ease-brand-out {muted
46
46
  ? 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0'
47
47
  : ''}"
48
48
  >
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
- import { toneSoftHover, toneText, type Tone } from '../../core/tones.js';
4
+ import { toneSoft, toneSoftHover, toneText, type Tone } from '../../core/tones.js';
5
5
  import { iconMd } from './icon.js';
6
6
 
7
7
  interface Props extends HTMLButtonAttributes {
@@ -28,6 +28,11 @@
28
28
  children,
29
29
  ...rest
30
30
  }: Props = $props();
31
+
32
+ // A chosen row speaks in the brand, the same as an active nav link — a tick
33
+ // alone leaves it the same grey as every row above it. An item that already
34
+ // carries a tone (a red `Delete`) keeps its own.
35
+ const chosen = $derived(toneSoft[tone === 'neutral' ? 'brand' : tone]);
31
36
  </script>
32
37
 
33
38
  <svelte:element
@@ -39,9 +44,11 @@
39
44
  aria-disabled={disabled ? 'true' : undefined}
40
45
  tabindex={disabled ? undefined : -1}
41
46
  class="mx-1 flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-left text-sm transition-colors duration-100 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-40
42
- {tone === 'neutral'
43
- ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
44
- : `${toneText[tone]} ${toneSoftHover[tone]}`}
47
+ {selected
48
+ ? `${chosen} font-medium`
49
+ : tone === 'neutral'
50
+ ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
51
+ : `${toneText[tone]} ${toneSoftHover[tone]}`}
45
52
  {className}"
46
53
  {...rest}
47
54
  >
@@ -40,6 +40,8 @@
40
40
  ...rest
41
41
  }: Props = $props();
42
42
 
43
+ const uid = $props.id();
44
+
43
45
  let dialog: HTMLDialogElement | null = $state(null);
44
46
 
45
47
  const closable = $derived(showClose ?? dismissible);
@@ -65,6 +67,8 @@
65
67
 
66
68
  <dialog
67
69
  bind:this={dialog}
70
+ aria-labelledby={title ? `${uid}-title` : undefined}
71
+ aria-describedby={description ? `${uid}-description` : undefined}
68
72
  onclose={close}
69
73
  onclick={onBackdrop}
70
74
  oncancel={(event) => {
@@ -81,10 +85,12 @@
81
85
  {#if icon}<span class={iconLg}>{@render icon()}</span>{/if}
82
86
  <div class="flex min-w-0 flex-1 flex-col gap-1">
83
87
  {#if title}
84
- <h2 class="font-heading text-base font-semibold text-text">{title}</h2>
88
+ <h2 id="{uid}-title" class="font-heading text-base font-semibold text-text">{title}</h2>
85
89
  {/if}
86
90
  {#if description}
87
- <p class="font-sans text-[13px] text-text-muted">{description}</p>
91
+ <p id="{uid}-description" class="font-sans text-[13px] text-text-muted">
92
+ {description}
93
+ </p>
88
94
  {/if}
89
95
  </div>
90
96
  {#if closable}
@@ -161,7 +161,7 @@
161
161
  type="button"
162
162
  onclick={() => remove(option)}
163
163
  aria-label="Remove {option.label}"
164
- class="inline-flex size-4 items-center justify-center rounded transition-colors duration-150 hover:bg-bg/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
164
+ class="inline-flex size-4 items-center justify-center rounded-full transition-colors duration-150 hover:bg-bg/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
165
165
  >
166
166
  <svg class="size-2.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
167
167
  <path
@@ -210,7 +210,7 @@
210
210
  inputEl?.focus();
211
211
  }}
212
212
  aria-label="Clear selection"
213
- class="shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
213
+ class="shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
214
214
  >
215
215
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
216
216
  <path
@@ -261,7 +261,7 @@
261
261
  {index === active ? toneSoft[tone] : 'text-text-secondary'}"
262
262
  >
263
263
  <span
264
- class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border transition-colors duration-100
264
+ class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-md border transition-colors duration-100
265
265
  {checked ? `border-transparent ${toneText[tone]}` : 'border-hairline-strong'}"
266
266
  >
267
267
  {#if checked}
@@ -111,7 +111,7 @@
111
111
  class="{link} {focusRing} {toneRing[tone]} {item.active
112
112
  ? toneSoft[tone]
113
113
  : 'text-text-secondary hover:bg-bg-inset hover:text-text'} {item.disabled
114
- ? 'pointer-events-none opacity-50'
114
+ ? 'pointer-events-none opacity-40'
115
115
  : ''}"
116
116
  >
117
117
  {#if item.icon}<span class={iconMd}>{@render item.icon()}</span>{/if}
@@ -164,7 +164,7 @@
164
164
  class="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 font-sans text-sm font-medium {item.active
165
165
  ? `${toneSoft[tone]}`
166
166
  : 'text-text-secondary hover:bg-bg-inset'} {item.disabled
167
- ? 'pointer-events-none opacity-50'
167
+ ? 'pointer-events-none opacity-40'
168
168
  : ''}"
169
169
  >
170
170
  <span class="flex min-w-0 items-center gap-2.5">
@@ -25,32 +25,51 @@
25
25
  children,
26
26
  ...rest
27
27
  }: Props = $props();
28
+
29
+ const uid = $props.id();
28
30
  </script>
29
31
 
30
- <label
31
- class="group inline-flex items-start gap-2.5 font-sans text-sm
32
- {disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer'} {className}"
32
+ <div
33
+ class="inline-block font-sans text-sm {disabled
34
+ ? 'pointer-events-none opacity-50'
35
+ : ''} {className}"
33
36
  >
34
- <input type="radio" bind:group {value} {disabled} class="peer sr-only" {...rest} />
35
- <span
36
- class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-bg {edge} transition-colors duration-150 ease-brand-out
37
+ <label class="group inline-flex items-start gap-2.5 {disabled ? '' : 'cursor-pointer'}">
38
+ <input
39
+ type="radio"
40
+ bind:group
41
+ {value}
42
+ {disabled}
43
+ aria-describedby={description ? `${uid}-description` : undefined}
44
+ class="peer sr-only"
45
+ {...rest}
46
+ />
47
+ <span
48
+ class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-bg {edge} transition-colors duration-150 ease-brand-out
37
49
  {tonePeerChecked[tone]} {peerFocusRing} {tonePeerFocus[tone]}"
38
- aria-hidden="true"
39
- >
50
+ aria-hidden="true"
51
+ >
52
+ <span
53
+ class="size-1.5 rounded-full bg-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
54
+ ></span>
55
+ </span>
56
+ {#if label || children}
57
+ <span class="flex flex-col gap-0.5">
58
+ {#if children}
59
+ {@render children()}
60
+ {:else if label}
61
+ <span class="leading-snug text-text">{label}</span>
62
+ {/if}
63
+ </span>
64
+ {/if}
65
+ </label>
66
+ {#if description}
67
+ <!-- ponytail: the indent tracks the 18px dot plus the label's gap; move one, move both. -->
40
68
  <span
41
- class="size-1.5 rounded-full bg-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
42
- ></span>
43
- </span>
44
- {#if label || description || children}
45
- <span class="flex flex-col gap-0.5">
46
- {#if children}
47
- {@render children()}
48
- {:else if label}
49
- <span class="leading-snug text-text">{label}</span>
50
- {/if}
51
- {#if description}
52
- <span class="text-[13px] leading-snug text-text-muted">{description}</span>
53
- {/if}
69
+ id="{uid}-description"
70
+ class="mt-0.5 block pl-[28px] text-[13px] leading-snug text-text-muted"
71
+ >
72
+ {description}
54
73
  </span>
55
74
  {/if}
56
- </label>
75
+ </div>
@@ -45,13 +45,24 @@
45
45
  children,
46
46
  ...rest
47
47
  }: Props = $props();
48
+
49
+ const uid = $props.id();
50
+ // The group's own hint and error sit outside every control in it, so the
51
+ // fieldset is what has to point at them.
52
+ const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
48
53
  </script>
49
54
 
50
- <fieldset class="flex flex-col gap-3 border-0 p-0 {className}" {...rest}>
55
+ <fieldset
56
+ aria-describedby={messageId}
57
+ class="flex flex-col gap-3 border-0 p-0 {className}"
58
+ {...rest}
59
+ >
51
60
  {#if legend}
52
61
  <legend class="mb-1 font-sans text-sm font-medium text-text">
53
62
  {legend}
54
- {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
63
+ {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span><span
64
+ class="sr-only">(required)</span
65
+ >{/if}
55
66
  </legend>
56
67
  {/if}
57
68
 
@@ -95,8 +106,8 @@
95
106
  </div>
96
107
 
97
108
  {#if error}
98
- <p class="font-sans text-[13px] text-danger">{error}</p>
109
+ <p id="{uid}-error" role="alert" class="font-sans text-[13px] text-danger">{error}</p>
99
110
  {:else if hint}
100
- <p class="font-sans text-[13px] text-text-muted">{hint}</p>
111
+ <p id="{uid}-hint" class="font-sans text-[13px] text-text-muted">{hint}</p>
101
112
  {/if}
102
113
  </fieldset>
@@ -73,7 +73,7 @@
73
73
  type="button"
74
74
  onclick={clear}
75
75
  aria-label="Clear search"
76
- class="-mr-1 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
76
+ class="-mr-1 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
77
77
  >
78
78
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
79
79
  <path
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
- import { focusRing, toneRing, type Tone } from '../../core/tones.js';
4
+ import { focusRing, toneRing, toneText, type Tone } from '../../core/tones.js';
5
5
  import { iconMd } from './icon.js';
6
6
  import { edge } from './lift.js';
7
7
 
@@ -93,7 +93,7 @@
93
93
  onclick={() => select(option)}
94
94
  class="inline-flex items-center justify-center rounded-xl font-sans font-medium whitespace-nowrap transition-all duration-150 ease-brand-out disabled:pointer-events-none disabled:opacity-40
95
95
  {iconOnly ? squares[size] : sizes[size]} {focusRing} {toneRing[tone]}
96
- {value === option.value ? `bg-bg text-text ${edge}` : 'text-text-muted hover:text-text'}"
96
+ {value === option.value ? `bg-bg ${toneText[tone]} ${edge}` : 'text-text-muted hover:text-text'}"
97
97
  >
98
98
  {#if option.icon}<span class={iconMd}>{@render option.icon()}</span>{/if}
99
99
  {#if !iconOnly}{option.label}{/if}
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLSelectAttributes } from 'svelte/elements';
4
- import { toneFocusBorder, type Tone } from '../../core/tones.js';
4
+ import { focusRing, toneFocusBorder, toneRing, type Tone } from '../../core/tones.js';
5
5
  import { edge } from './lift.js';
6
6
 
7
7
  export interface SelectOption {
@@ -36,7 +36,9 @@
36
36
  <select
37
37
  bind:value
38
38
  aria-invalid={invalid ? 'true' : undefined}
39
- class="h-10 w-full appearance-none rounded-xl border bg-bg py-0 pr-9 pl-3 font-sans text-sm text-text {edge} transition-colors duration-150 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-50
39
+ class="{focusRing} {toneRing[
40
+ tone
41
+ ]} h-10 w-full appearance-none rounded-xl border bg-bg py-0 pr-9 pl-3 font-sans text-sm text-text {edge} transition-colors duration-150 ease-brand-out disabled:pointer-events-none disabled:opacity-50
40
42
  {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusBorder[tone]}`} {className}"
41
43
  {...rest}
42
44
  >
@@ -36,6 +36,7 @@
36
36
 
37
37
  <script lang="ts">
38
38
  import { iconMd } from './icon.js';
39
+ import { soft } from './lift.js';
39
40
  import { focusRing, toneRing, toneSoft, type Tone } from '../../core/tones.js';
40
41
 
41
42
  interface Props {
@@ -90,7 +91,7 @@
90
91
  const surfaces: Record<SidebarVariant, string> = {
91
92
  plain: 'h-full bg-transparent',
92
93
  filled: 'h-full border-r border-hairline bg-bg-alt',
93
- floating: 'm-3 h-[calc(100%-1.5rem)] rounded-2xl border border-hairline bg-bg-alt shadow-sm'
94
+ floating: `m-3 h-[calc(100%-1.5rem)] rounded-2xl border border-hairline bg-bg-alt ${soft}`
94
95
  };
95
96
 
96
97
  const row =
@@ -29,7 +29,7 @@
29
29
  </script>
30
30
 
31
31
  {#if variant === 'text' && lines > 1}
32
- <div class="flex w-full flex-col gap-2 {className}" {...rest}>
32
+ <div aria-hidden="true" class="flex w-full flex-col gap-2 {className}" {...rest}>
33
33
  {#each { length: lines }, i (i)}
34
34
  <div
35
35
  class="h-4 animate-pulse rounded-xl bg-bg-inset {i === lines - 1 ? 'w-2/3' : 'w-full'}"
@@ -39,6 +39,7 @@
39
39
  </div>
40
40
  {:else}
41
41
  <div
42
+ aria-hidden="true"
42
43
  class="animate-pulse bg-bg-inset {variants[variant]} {className}"
43
44
  style="{width ? `width:${width};` : ''}{height ? `height:${height};` : ''}"
44
45
  {...rest}