@nqmcreative/ui 0.4.1 → 0.4.3

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 (132) 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 +9 -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 +12 -3
  14. package/dist/styles/matte/CommandPalette.svelte +1 -1
  15. package/dist/styles/matte/ConfirmDialog.svelte +10 -2
  16. package/dist/styles/matte/ContextMenu.svelte +1 -1
  17. package/dist/styles/matte/DatePicker.svelte +10 -3
  18. package/dist/styles/matte/Divider.svelte +1 -1
  19. package/dist/styles/matte/Drawer.svelte +10 -2
  20. package/dist/styles/matte/Dropdown.svelte +1 -1
  21. package/dist/styles/matte/Field.svelte +59 -3
  22. package/dist/styles/matte/Footer.svelte +14 -3
  23. package/dist/styles/matte/Input.svelte +9 -2
  24. package/dist/styles/matte/Label.svelte +3 -1
  25. package/dist/styles/matte/Link.svelte +13 -2
  26. package/dist/styles/matte/MenuItem.svelte +11 -4
  27. package/dist/styles/matte/Modal.svelte +11 -3
  28. package/dist/styles/matte/MultiSelect.svelte +1 -1
  29. package/dist/styles/matte/Navbar.svelte +2 -2
  30. package/dist/styles/matte/Popover.svelte +1 -1
  31. package/dist/styles/matte/Radio.svelte +41 -22
  32. package/dist/styles/matte/RadioGroup.svelte +15 -4
  33. package/dist/styles/matte/Select.svelte +4 -2
  34. package/dist/styles/matte/Skeleton.svelte +2 -1
  35. package/dist/styles/matte/Stat.svelte +27 -3
  36. package/dist/styles/matte/Switch.svelte +6 -1
  37. package/dist/styles/matte/Tabs.svelte +1 -1
  38. package/dist/styles/matte/Textarea.svelte +4 -2
  39. package/dist/styles/matte/Toaster.svelte +21 -3
  40. package/dist/styles/matte/Tooltip.svelte +8 -1
  41. package/dist/styles/matte/index.d.ts +1 -1
  42. package/dist/styles/matte/index.js +1 -1
  43. package/dist/styles/matte/theme.css +64 -1
  44. package/dist/styles/paper/AccordionItem.svelte +38 -11
  45. package/dist/styles/paper/AccordionItem.svelte.d.ts +1 -1
  46. package/dist/styles/paper/Alert.svelte +2 -2
  47. package/dist/styles/paper/Avatar.svelte +14 -3
  48. package/dist/styles/paper/Button.svelte +9 -2
  49. package/dist/styles/paper/Card.svelte +1 -1
  50. package/dist/styles/paper/Checkbox.svelte +55 -36
  51. package/dist/styles/paper/CheckboxGroup.svelte +15 -4
  52. package/dist/styles/paper/Combobox.svelte +12 -3
  53. package/dist/styles/paper/CommandPalette.svelte +1 -1
  54. package/dist/styles/paper/ConfirmDialog.svelte +8 -2
  55. package/dist/styles/paper/ContextMenu.svelte +1 -1
  56. package/dist/styles/paper/DatePicker.svelte +10 -3
  57. package/dist/styles/paper/Divider.svelte +1 -1
  58. package/dist/styles/paper/Drawer.svelte +8 -2
  59. package/dist/styles/paper/Dropdown.svelte +1 -1
  60. package/dist/styles/paper/Field.svelte +59 -3
  61. package/dist/styles/paper/Footer.svelte +14 -3
  62. package/dist/styles/paper/Input.svelte +9 -2
  63. package/dist/styles/paper/Label.svelte +3 -1
  64. package/dist/styles/paper/Link.svelte +13 -2
  65. package/dist/styles/paper/MenuItem.svelte +11 -4
  66. package/dist/styles/paper/Modal.svelte +9 -3
  67. package/dist/styles/paper/MultiSelect.svelte +1 -1
  68. package/dist/styles/paper/Navbar.svelte +2 -2
  69. package/dist/styles/paper/Popover.svelte +1 -1
  70. package/dist/styles/paper/Radio.svelte +41 -22
  71. package/dist/styles/paper/RadioGroup.svelte +15 -4
  72. package/dist/styles/paper/SegmentedControl.svelte +2 -2
  73. package/dist/styles/paper/Select.svelte +4 -2
  74. package/dist/styles/paper/Skeleton.svelte +2 -1
  75. package/dist/styles/paper/Stat.svelte +24 -2
  76. package/dist/styles/paper/Switch.svelte +6 -1
  77. package/dist/styles/paper/Tabs.svelte +1 -1
  78. package/dist/styles/paper/Textarea.svelte +4 -2
  79. package/dist/styles/paper/Toaster.svelte +19 -3
  80. package/dist/styles/paper/Tooltip.svelte +8 -1
  81. package/dist/styles/paper/index.d.ts +1 -1
  82. package/dist/styles/paper/index.js +1 -1
  83. package/dist/styles/paper/theme.css +85 -13
  84. package/dist/styles/sprout/Alert.svelte +3 -3
  85. package/dist/styles/sprout/Avatar.svelte +14 -3
  86. package/dist/styles/sprout/Breadcrumb.svelte +1 -1
  87. package/dist/styles/sprout/Button.svelte +8 -4
  88. package/dist/styles/sprout/Calendar.svelte +1 -1
  89. package/dist/styles/sprout/Card.svelte +2 -2
  90. package/dist/styles/sprout/Checkbox.svelte +55 -36
  91. package/dist/styles/sprout/CheckboxGroup.svelte +15 -4
  92. package/dist/styles/sprout/ColorInput.svelte +1 -1
  93. package/dist/styles/sprout/Combobox.svelte +12 -3
  94. package/dist/styles/sprout/ConfirmDialog.svelte +8 -2
  95. package/dist/styles/sprout/DatePicker.svelte +11 -4
  96. package/dist/styles/sprout/Divider.svelte +1 -1
  97. package/dist/styles/sprout/Drawer.svelte +8 -2
  98. package/dist/styles/sprout/Dropzone.svelte +1 -1
  99. package/dist/styles/sprout/Field.svelte +59 -3
  100. package/dist/styles/sprout/FileInput.svelte +3 -3
  101. package/dist/styles/sprout/Footer.svelte +14 -3
  102. package/dist/styles/sprout/Input.svelte +9 -2
  103. package/dist/styles/sprout/Label.svelte +3 -1
  104. package/dist/styles/sprout/Link.svelte +13 -2
  105. package/dist/styles/sprout/LogoCloud.svelte +1 -1
  106. package/dist/styles/sprout/MenuItem.svelte +11 -4
  107. package/dist/styles/sprout/Modal.svelte +8 -2
  108. package/dist/styles/sprout/MultiSelect.svelte +3 -3
  109. package/dist/styles/sprout/Navbar.svelte +2 -2
  110. package/dist/styles/sprout/Radio.svelte +41 -22
  111. package/dist/styles/sprout/RadioGroup.svelte +15 -4
  112. package/dist/styles/sprout/SearchInput.svelte +1 -1
  113. package/dist/styles/sprout/SegmentedControl.svelte +2 -2
  114. package/dist/styles/sprout/Select.svelte +4 -2
  115. package/dist/styles/sprout/Sidebar.svelte +2 -1
  116. package/dist/styles/sprout/Skeleton.svelte +2 -1
  117. package/dist/styles/sprout/Stat.svelte +24 -2
  118. package/dist/styles/sprout/Switch.svelte +6 -1
  119. package/dist/styles/sprout/Table.svelte +1 -1
  120. package/dist/styles/sprout/Tabs.svelte +1 -1
  121. package/dist/styles/sprout/TagsInput.svelte +1 -1
  122. package/dist/styles/sprout/Textarea.svelte +4 -2
  123. package/dist/styles/sprout/TimeInput.svelte +1 -1
  124. package/dist/styles/sprout/Toaster.svelte +22 -6
  125. package/dist/styles/sprout/Tooltip.svelte +8 -1
  126. package/dist/styles/sprout/index.d.ts +2 -2
  127. package/dist/styles/sprout/index.js +2 -2
  128. package/dist/styles/sprout/lift.d.ts +14 -0
  129. package/dist/styles/sprout/lift.js +23 -0
  130. package/dist/styles/sprout/theme.css +68 -24
  131. package/package.json +1 -1
  132. package/registry.json +4 -2
@@ -14,7 +14,7 @@
14
14
  toneText,
15
15
  type Tone
16
16
  } from '../../core/tones.js';
17
- import { edge, lift } from './lift.js';
17
+ import { lift, liftSoft } from './lift.js';
18
18
 
19
19
  export type ButtonVariant = 'solid' | 'soft' | 'outline' | 'ghost' | 'link' | 'primary';
20
20
  export type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -92,11 +92,11 @@
92
92
  case 'solid':
93
93
  return `border-transparent ${toneSolid[tone]} ${toneSolidHover[tone]} ${lift[tone]}`;
94
94
  case 'soft':
95
- return `border-transparent ${toneSoft[tone]} ${toneSoftHover[tone]} ${edge}`;
95
+ return `border-transparent ${toneSoft[tone]} ${toneSoftHover[tone]} ${liftSoft[tone]}`;
96
96
  // Outline keeps its own colours on hover — swapping to a solid fill is
97
97
  // matte's move, and it fights sprout's soft surfaces.
98
98
  case 'outline':
99
- return `bg-bg ${edge} ${toneText[tone]} ${toneBorderSoft[tone]} hover:bg-bg-alt`;
99
+ return `bg-bg ${liftSoft[tone]} ${toneText[tone]} ${toneBorderSoft[tone]} hover:bg-bg-alt`;
100
100
  // Nothing raised, so nothing to sink: ghost stays flat on the page.
101
101
  case 'ghost':
102
102
  return `border-transparent bg-transparent ${toneText[tone]} ${toneSoftHover[tone]} active:translate-y-0`;
@@ -116,6 +116,10 @@
116
116
  const iconClass = $derived(size === 'sm' ? iconSm : iconMd);
117
117
 
118
118
  async function handleClick(event: MouseEvent) {
119
+ if (inert) {
120
+ event.preventDefault();
121
+ return;
122
+ }
119
123
  const result = onclick?.(event);
120
124
  // Anything else is a plain handler: nothing to wait for, nothing to lock.
121
125
  if (!(result instanceof Promise)) return;
@@ -134,7 +138,7 @@
134
138
  disabled={href ? undefined : inert}
135
139
  aria-disabled={href && inert ? 'true' : undefined}
136
140
  aria-busy={busy ? 'true' : undefined}
137
- role={href ? 'button' : undefined}
141
+ tabindex={href && inert ? -1 : undefined}
138
142
  class="{base} {focusRing} {toneRing[tone]} {variantClass} {isLink
139
143
  ? ''
140
144
  : iconOnly
@@ -189,7 +189,7 @@
189
189
  type="button"
190
190
  onclick={() => pick(now)}
191
191
  disabled={blocked(now)}
192
- class="self-start rounded px-1 text-[13px] font-medium transition-colors duration-150 disabled:pointer-events-none disabled:opacity-40 {toneText[
192
+ class="self-start rounded-md px-1 text-[13px] font-medium transition-colors duration-150 disabled:pointer-events-none disabled:opacity-40 {toneText[
193
193
  tone
194
194
  ]} hover:underline hover:underline-offset-2 {focusRing} {toneRing[tone]}"
195
195
  >
@@ -40,7 +40,7 @@
40
40
  ...rest
41
41
  }: Props = $props();
42
42
 
43
- // The default card is the one the style is named for: cream, a hairline, and
43
+ // The default card is the one the style is named for: white, a hairline, and
44
44
  // the lip. `filled` and `tinted` stay flat on purpose — a tinted panel that
45
45
  // also floats is two claims about the same box.
46
46
  const variants = $derived({
@@ -52,7 +52,7 @@
52
52
 
53
53
  <div
54
54
  class="flex flex-col rounded-[28px] {variants[variant]}
55
- {accent ? `border-l-4 ${toneBorderLeft[tone]}` : ''}
55
+ {accent ? `border-l-2 ${toneBorderLeft[tone]}` : ''}
56
56
  {interactive
57
57
  ? `transition-[box-shadow,transform,border-color] duration-150 ease-brand-out hover:-translate-y-0.5 ${softOnHover} ${toneHoverBorder[tone]}`
58
58
  : ''}
@@ -26,6 +26,8 @@
26
26
  ...rest
27
27
  }: Props = $props();
28
28
 
29
+ const uid = $props.id();
30
+
29
31
  let el: HTMLInputElement | null = $state(null);
30
32
 
31
33
  $effect(() => {
@@ -33,44 +35,61 @@
33
35
  });
34
36
  </script>
35
37
 
36
- <label
37
- class="group inline-flex items-start gap-2.5 font-sans text-sm
38
- {disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer'} {className}"
38
+ <div
39
+ class="inline-block font-sans text-sm {disabled
40
+ ? 'pointer-events-none opacity-50'
41
+ : ''} {className}"
39
42
  >
40
- <input bind:this={el} type="checkbox" bind:checked {disabled} class="peer sr-only" {...rest} />
41
- <span
42
- class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded border border-hairline-strong bg-bg {edge} transition-colors duration-150 ease-brand-out
43
+ <label class="group inline-flex items-start gap-2.5 {disabled ? '' : 'cursor-pointer'}">
44
+ <input
45
+ bind:this={el}
46
+ type="checkbox"
47
+ bind:checked
48
+ {disabled}
49
+ aria-describedby={description ? `${uid}-description` : undefined}
50
+ class="peer sr-only"
51
+ {...rest}
52
+ />
53
+ <span
54
+ class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-md border border-hairline-strong bg-bg {edge} transition-colors duration-150 ease-brand-out
43
55
  {tonePeerChecked[tone]} {peerFocusRing} {tonePeerFocus[tone]}"
44
- aria-hidden="true"
45
- >
46
- {#if indeterminate}
47
- <span class="h-0.5 w-2.5 rounded-full bg-text-muted"></span>
48
- {:else}
49
- <svg
50
- class="size-3 text-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
51
- viewBox="0 0 12 12"
52
- fill="none"
53
- >
54
- <path
55
- d="m2 6 2.6 2.6L10 3.2"
56
- stroke="currentColor"
57
- stroke-width="2"
58
- stroke-linecap="round"
59
- stroke-linejoin="round"
60
- />
61
- </svg>
62
- {/if}
63
- </span>
64
- {#if label || description || children}
65
- <span class="flex flex-col gap-0.5">
66
- {#if children}
67
- {@render children()}
68
- {:else if label}
69
- <span class="leading-snug text-text">{label}</span>
70
- {/if}
71
- {#if description}
72
- <span class="text-[13px] leading-snug text-text-muted">{description}</span>
56
+ aria-hidden="true"
57
+ >
58
+ {#if indeterminate}
59
+ <span class="h-0.5 w-2.5 rounded-full bg-text-muted"></span>
60
+ {:else}
61
+ <svg
62
+ class="size-3 text-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
63
+ viewBox="0 0 12 12"
64
+ fill="none"
65
+ >
66
+ <path
67
+ d="m2 6 2.6 2.6L10 3.2"
68
+ stroke="currentColor"
69
+ stroke-width="2"
70
+ stroke-linecap="round"
71
+ stroke-linejoin="round"
72
+ />
73
+ </svg>
73
74
  {/if}
74
75
  </span>
76
+ {#if label || children}
77
+ <span class="flex flex-col gap-0.5">
78
+ {#if children}
79
+ {@render children()}
80
+ {:else if label}
81
+ <span class="leading-snug text-text">{label}</span>
82
+ {/if}
83
+ </span>
84
+ {/if}
85
+ </label>
86
+ {#if description}
87
+ <!-- ponytail: the indent tracks the 18px box plus the label's gap; move one, move both. -->
88
+ <span
89
+ id="{uid}-description"
90
+ class="mt-0.5 block pl-[28px] text-[13px] leading-snug text-text-muted"
91
+ >
92
+ {description}
93
+ </span>
75
94
  {/if}
76
- </label>
95
+ </div>
@@ -51,6 +51,11 @@
51
51
  ...rest
52
52
  }: Props = $props();
53
53
 
54
+ const uid = $props.id();
55
+ // The group's own hint and error sit outside every control in it, so the
56
+ // fieldset is what has to point at them.
57
+ const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
58
+
54
59
  const full = $derived(max > 0 && value.length >= max);
55
60
 
56
61
  function toggle(option: CheckboxOption) {
@@ -61,11 +66,17 @@
61
66
  }
62
67
  </script>
63
68
 
64
- <fieldset class="flex flex-col gap-3 border-0 p-0 {className}" {...rest}>
69
+ <fieldset
70
+ aria-describedby={messageId}
71
+ class="flex flex-col gap-3 border-0 p-0 {className}"
72
+ {...rest}
73
+ >
65
74
  {#if legend}
66
75
  <legend class="mb-1 font-sans text-sm font-medium text-text">
67
76
  {legend}
68
- {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
77
+ {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span><span
78
+ class="sr-only">(required)</span
79
+ >{/if}
69
80
  </legend>
70
81
  {/if}
71
82
 
@@ -111,8 +122,8 @@
111
122
  </div>
112
123
 
113
124
  {#if error}
114
- <p class="font-sans text-[13px] text-danger">{error}</p>
125
+ <p id="{uid}-error" role="alert" class="font-sans text-[13px] text-danger">{error}</p>
115
126
  {:else if hint}
116
- <p class="font-sans text-[13px] text-text-muted">{hint}</p>
127
+ <p id="{uid}-hint" class="font-sans text-[13px] text-text-muted">{hint}</p>
117
128
  {/if}
118
129
  </fieldset>
@@ -74,7 +74,7 @@
74
74
  >
75
75
  {#snippet prefix()}
76
76
  <span
77
- class="relative grid size-5 place-items-center overflow-hidden rounded border border-hairline-strong"
77
+ class="relative grid size-5 place-items-center overflow-hidden rounded-lg border border-hairline-strong"
78
78
  >
79
79
  <span class="absolute inset-0" style="background-color: {resolved}"></span>
80
80
  <input
@@ -3,7 +3,14 @@
3
3
  import { anchored } from '../../core/actions/anchor.js';
4
4
  import { clickOutside, portal } from '../../core/actions/dismissable.js';
5
5
  import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
6
- import { toneFocusWithinBorder, toneSoft, toneText, type Tone } from '../../core/tones.js';
6
+ import {
7
+ focusWithinRing,
8
+ toneFocusWithinBorder,
9
+ toneFocusWithinRing,
10
+ toneSoft,
11
+ toneText,
12
+ type Tone
13
+ } from '../../core/tones.js';
7
14
  import { iconMd } from './icon.js';
8
15
  import { edge, soft } from './lift.js';
9
16
 
@@ -144,7 +151,9 @@
144
151
 
145
152
  <div
146
153
  bind:this={wrapper}
147
- class="relative inline-flex w-full items-center gap-2 rounded-xl border bg-bg pr-1.5 pl-3 {edge} transition-colors duration-150 ease-brand-out
154
+ class="relative inline-flex w-full items-center gap-2 rounded-xl border bg-bg pr-1.5 pl-3 {edge} transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
155
+ tone
156
+ ]}
148
157
  {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
149
158
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
150
159
  >
@@ -178,7 +187,7 @@
178
187
  type="button"
179
188
  onclick={clear}
180
189
  aria-label="Clear selection"
181
- 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"
190
+ 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"
182
191
  >
183
192
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
184
193
  <path
@@ -36,6 +36,8 @@
36
36
  children
37
37
  }: Props = $props();
38
38
 
39
+ const uid = $props.id();
40
+
39
41
  async function confirm() {
40
42
  try {
41
43
  loading = true;
@@ -55,6 +57,8 @@
55
57
 
56
58
  <Modal
57
59
  bind:open
60
+ aria-labelledby="{uid}-title"
61
+ aria-describedby={description ? `${uid}-description` : undefined}
58
62
  size="sm"
59
63
  dismissible={!loading}
60
64
  showClose={false}
@@ -73,9 +77,11 @@
73
77
  {/if}
74
78
  </span>
75
79
  <div class="flex min-w-0 flex-1 flex-col gap-1.5">
76
- <h2 class="font-heading text-base font-semibold text-text">{title}</h2>
80
+ <h2 id="{uid}-title" class="font-heading text-base font-semibold text-text">{title}</h2>
77
81
  {#if description}
78
- <p class="text-[13px] leading-relaxed text-text-secondary">{description}</p>
82
+ <p id="{uid}-description" class="text-[13px] leading-relaxed text-text-secondary">
83
+ {description}
84
+ </p>
79
85
  {/if}
80
86
  {#if children}
81
87
  {@render children()}
@@ -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
  >