@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
@@ -25,6 +25,8 @@
25
25
  ...rest
26
26
  }: Props = $props();
27
27
 
28
+ const uid = $props.id();
29
+
28
30
  let el: HTMLInputElement | null = $state(null);
29
31
 
30
32
  $effect(() => {
@@ -32,44 +34,61 @@
32
34
  });
33
35
  </script>
34
36
 
35
- <label
36
- class="group inline-flex items-start gap-2.5 font-sans text-sm
37
- {disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer'} {className}"
37
+ <div
38
+ class="inline-block font-sans text-sm {disabled
39
+ ? 'pointer-events-none opacity-50'
40
+ : ''} {className}"
38
41
  >
39
- <input bind:this={el} type="checkbox" bind:checked {disabled} class="peer sr-only" {...rest} />
40
- <span
41
- class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded border border-hairline-strong bg-bg shadow-xs transition-colors duration-150 ease-brand-out
42
+ <label class="group inline-flex items-start gap-2.5 {disabled ? '' : 'cursor-pointer'}">
43
+ <input
44
+ bind:this={el}
45
+ type="checkbox"
46
+ bind:checked
47
+ {disabled}
48
+ aria-describedby={description ? `${uid}-description` : undefined}
49
+ class="peer sr-only"
50
+ {...rest}
51
+ />
52
+ <span
53
+ class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded border border-hairline-strong bg-bg shadow-xs transition-colors duration-150 ease-brand-out
42
54
  {tonePeerChecked[tone]} {peerFocusRing} {tonePeerFocus[tone]}"
43
- aria-hidden="true"
44
- >
45
- {#if indeterminate}
46
- <span class="h-0.5 w-2.5 rounded-full bg-text-muted"></span>
47
- {:else}
48
- <svg
49
- class="size-3 text-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
50
- viewBox="0 0 12 12"
51
- fill="none"
52
- >
53
- <path
54
- d="m2 6 2.6 2.6L10 3.2"
55
- stroke="currentColor"
56
- stroke-width="2"
57
- stroke-linecap="round"
58
- stroke-linejoin="round"
59
- />
60
- </svg>
61
- {/if}
62
- </span>
63
- {#if label || description || children}
64
- <span class="flex flex-col gap-0.5">
65
- {#if children}
66
- {@render children()}
67
- {:else if label}
68
- <span class="leading-snug text-text">{label}</span>
69
- {/if}
70
- {#if description}
71
- <span class="text-[13px] leading-snug text-text-muted">{description}</span>
55
+ aria-hidden="true"
56
+ >
57
+ {#if indeterminate}
58
+ <span class="h-0.5 w-2.5 rounded-full bg-text-muted"></span>
59
+ {:else}
60
+ <svg
61
+ class="size-3 text-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
62
+ viewBox="0 0 12 12"
63
+ fill="none"
64
+ >
65
+ <path
66
+ d="m2 6 2.6 2.6L10 3.2"
67
+ stroke="currentColor"
68
+ stroke-width="2"
69
+ stroke-linecap="round"
70
+ stroke-linejoin="round"
71
+ />
72
+ </svg>
72
73
  {/if}
73
74
  </span>
75
+ {#if label || children}
76
+ <span class="flex flex-col gap-0.5">
77
+ {#if children}
78
+ {@render children()}
79
+ {:else if label}
80
+ <span class="leading-snug text-text">{label}</span>
81
+ {/if}
82
+ </span>
83
+ {/if}
84
+ </label>
85
+ {#if description}
86
+ <!-- ponytail: the indent tracks the 18px box plus the label's gap; move one, move both. -->
87
+ <span
88
+ id="{uid}-description"
89
+ class="mt-0.5 block pl-[28px] text-[13px] leading-snug text-text-muted"
90
+ >
91
+ {description}
92
+ </span>
74
93
  {/if}
75
- </label>
94
+ </div>
@@ -50,6 +50,11 @@
50
50
  ...rest
51
51
  }: Props = $props();
52
52
 
53
+ const uid = $props.id();
54
+ // The group's own hint and error sit outside every control in it, so the
55
+ // fieldset is what has to point at them.
56
+ const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
57
+
53
58
  const full = $derived(max > 0 && value.length >= max);
54
59
 
55
60
  function toggle(option: CheckboxOption) {
@@ -60,11 +65,17 @@
60
65
  }
61
66
  </script>
62
67
 
63
- <fieldset class="flex flex-col gap-3 border-0 p-0 {className}" {...rest}>
68
+ <fieldset
69
+ aria-describedby={messageId}
70
+ class="flex flex-col gap-3 border-0 p-0 {className}"
71
+ {...rest}
72
+ >
64
73
  {#if legend}
65
74
  <legend class="mb-1 font-sans text-sm font-medium text-text">
66
75
  {legend}
67
- {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
76
+ {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span><span
77
+ class="sr-only">(required)</span
78
+ >{/if}
68
79
  </legend>
69
80
  {/if}
70
81
 
@@ -110,8 +121,8 @@
110
121
  </div>
111
122
 
112
123
  {#if error}
113
- <p class="font-sans text-[13px] text-danger">{error}</p>
124
+ <p id="{uid}-error" role="alert" class="font-sans text-[13px] text-danger">{error}</p>
114
125
  {:else if hint}
115
- <p class="font-sans text-[13px] text-text-muted">{hint}</p>
126
+ <p id="{uid}-hint" class="font-sans text-[13px] text-text-muted">{hint}</p>
116
127
  {/if}
117
128
  </fieldset>
@@ -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
 
9
16
  export interface ComboboxOption {
@@ -143,7 +150,9 @@
143
150
 
144
151
  <div
145
152
  bind:this={wrapper}
146
- class="relative inline-flex w-full items-center gap-2 rounded-md border bg-bg pr-1.5 pl-3 shadow-xs transition-colors duration-150 ease-brand-out
153
+ class="relative inline-flex w-full items-center gap-2 rounded-md border bg-bg pr-1.5 pl-3 shadow-xs transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
154
+ tone
155
+ ]}
147
156
  {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
148
157
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
149
158
  >
@@ -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
 
9
14
  interface Props {
@@ -94,7 +99,9 @@
94
99
 
95
100
  <div
96
101
  bind:this={wrapper}
97
- class="inline-flex w-full items-center gap-1 rounded-md border bg-bg pr-1 pl-3 shadow-xs transition-colors duration-150 ease-brand-out
102
+ class="inline-flex w-full items-center gap-1 rounded-md border bg-bg pr-1 pl-3 shadow-xs transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
103
+ tone
104
+ ]}
98
105
  {invalid ? 'border-danger' : `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
99
106
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
100
107
  >
@@ -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>
@@ -38,6 +38,8 @@
38
38
  ...rest
39
39
  }: Props = $props();
40
40
 
41
+ const uid = $props.id();
42
+
41
43
  let dialog: HTMLDialogElement | null = $state(null);
42
44
 
43
45
  const horizontal = $derived(side === 'left' || side === 'right');
@@ -80,6 +82,8 @@
80
82
 
81
83
  <dialog
82
84
  bind:this={dialog}
85
+ aria-labelledby={title ? `${uid}-title` : undefined}
86
+ aria-describedby={description ? `${uid}-description` : undefined}
83
87
  onclose={close}
84
88
  onclick={(event) => {
85
89
  if (dismissible && isBackdropClick(event, dialog)) close();
@@ -97,10 +101,12 @@
97
101
  {#if icon}<span class={iconLg}>{@render icon()}</span>{/if}
98
102
  <div class="flex min-w-0 flex-1 flex-col gap-1">
99
103
  {#if title}
100
- <h2 class="font-heading text-base font-semibold text-text">{title}</h2>
104
+ <h2 id="{uid}-title" class="font-heading text-base font-semibold text-text">{title}</h2>
101
105
  {/if}
102
106
  {#if description}
103
- <p class="font-sans text-[13px] text-text-muted">{description}</p>
107
+ <p id="{uid}-description" class="font-sans text-[13px] text-text-muted">
108
+ {description}
109
+ </p>
104
110
  {/if}
105
111
  </div>
106
112
  {#if dismissible}
@@ -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>
@@ -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
 
6
11
  export type InputSize = 'sm' | 'md' | 'lg';
7
12
 
@@ -37,7 +42,9 @@
37
42
  </script>
38
43
 
39
44
  <span
40
- class="inline-flex w-full items-center gap-2 rounded-md border bg-bg px-3 shadow-xs transition-colors duration-150 ease-brand-out
45
+ class="inline-flex w-full items-center gap-2 rounded-md border bg-bg px-3 shadow-xs transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
46
+ tone
47
+ ]}
41
48
  {sizes[size]}
42
49
  {invalid
43
50
  ? '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-sm 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>
@@ -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-md 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
  >
@@ -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 closable = $derived(showClose ?? dismissible);
@@ -64,6 +66,8 @@
64
66
 
65
67
  <dialog
66
68
  bind:this={dialog}
69
+ aria-labelledby={title ? `${uid}-title` : undefined}
70
+ aria-describedby={description ? `${uid}-description` : undefined}
67
71
  onclose={close}
68
72
  onclick={onBackdrop}
69
73
  oncancel={(event) => {
@@ -80,10 +84,12 @@
80
84
  {#if icon}<span class={iconLg}>{@render icon()}</span>{/if}
81
85
  <div class="flex min-w-0 flex-1 flex-col gap-1">
82
86
  {#if title}
83
- <h2 class="font-heading text-base font-semibold text-text">{title}</h2>
87
+ <h2 id="{uid}-title" class="font-heading text-base font-semibold text-text">{title}</h2>
84
88
  {/if}
85
89
  {#if description}
86
- <p class="font-sans text-[13px] text-text-muted">{description}</p>
90
+ <p id="{uid}-description" class="font-sans text-[13px] text-text-muted">
91
+ {description}
92
+ </p>
87
93
  {/if}
88
94
  </div>
89
95
  {#if closable}
@@ -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-md 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">
@@ -24,32 +24,51 @@
24
24
  children,
25
25
  ...rest
26
26
  }: Props = $props();
27
+
28
+ const uid = $props.id();
27
29
  </script>
28
30
 
29
- <label
30
- class="group inline-flex items-start gap-2.5 font-sans text-sm
31
- {disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer'} {className}"
31
+ <div
32
+ class="inline-block font-sans text-sm {disabled
33
+ ? 'pointer-events-none opacity-50'
34
+ : ''} {className}"
32
35
  >
33
- <input type="radio" bind:group {value} {disabled} class="peer sr-only" {...rest} />
34
- <span
35
- class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-bg shadow-xs transition-colors duration-150 ease-brand-out
36
+ <label class="group inline-flex items-start gap-2.5 {disabled ? '' : 'cursor-pointer'}">
37
+ <input
38
+ type="radio"
39
+ bind:group
40
+ {value}
41
+ {disabled}
42
+ aria-describedby={description ? `${uid}-description` : undefined}
43
+ class="peer sr-only"
44
+ {...rest}
45
+ />
46
+ <span
47
+ class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-bg shadow-xs transition-colors duration-150 ease-brand-out
36
48
  {tonePeerChecked[tone]} {peerFocusRing} {tonePeerFocus[tone]}"
37
- aria-hidden="true"
38
- >
49
+ aria-hidden="true"
50
+ >
51
+ <span
52
+ class="size-1.5 rounded-full bg-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
53
+ ></span>
54
+ </span>
55
+ {#if label || children}
56
+ <span class="flex flex-col gap-0.5">
57
+ {#if children}
58
+ {@render children()}
59
+ {:else if label}
60
+ <span class="leading-snug text-text">{label}</span>
61
+ {/if}
62
+ </span>
63
+ {/if}
64
+ </label>
65
+ {#if description}
66
+ <!-- ponytail: the indent tracks the 18px dot plus the label's gap; move one, move both. -->
39
67
  <span
40
- class="size-1.5 rounded-full bg-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
41
- ></span>
42
- </span>
43
- {#if label || description || children}
44
- <span class="flex flex-col gap-0.5">
45
- {#if children}
46
- {@render children()}
47
- {:else if label}
48
- <span class="leading-snug text-text">{label}</span>
49
- {/if}
50
- {#if description}
51
- <span class="text-[13px] leading-snug text-text-muted">{description}</span>
52
- {/if}
68
+ id="{uid}-description"
69
+ class="mt-0.5 block pl-[28px] text-[13px] leading-snug text-text-muted"
70
+ >
71
+ {description}
53
72
  </span>
54
73
  {/if}
55
- </label>
74
+ </div>
@@ -44,13 +44,24 @@
44
44
  children,
45
45
  ...rest
46
46
  }: Props = $props();
47
+
48
+ const uid = $props.id();
49
+ // The group's own hint and error sit outside every control in it, so the
50
+ // fieldset is what has to point at them.
51
+ const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
47
52
  </script>
48
53
 
49
- <fieldset class="flex flex-col gap-3 border-0 p-0 {className}" {...rest}>
54
+ <fieldset
55
+ aria-describedby={messageId}
56
+ class="flex flex-col gap-3 border-0 p-0 {className}"
57
+ {...rest}
58
+ >
50
59
  {#if legend}
51
60
  <legend class="mb-1 font-sans text-sm font-medium text-text">
52
61
  {legend}
53
- {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
62
+ {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span><span
63
+ class="sr-only">(required)</span
64
+ >{/if}
54
65
  </legend>
55
66
  {/if}
56
67
 
@@ -94,8 +105,8 @@
94
105
  </div>
95
106
 
96
107
  {#if error}
97
- <p class="font-sans text-[13px] text-danger">{error}</p>
108
+ <p id="{uid}-error" role="alert" class="font-sans text-[13px] text-danger">{error}</p>
98
109
  {:else if hint}
99
- <p class="font-sans text-[13px] text-text-muted">{hint}</p>
110
+ <p id="{uid}-hint" class="font-sans text-[13px] text-text-muted">{hint}</p>
100
111
  {/if}
101
112
  </fieldset>