@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
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  import { type Tone } from '../../core/tones.js';
4
- interface Props extends HTMLAttributes<HTMLDetailsElement> {
4
+ interface Props extends HTMLAttributes<HTMLDivElement> {
5
5
  title: string;
6
6
  open?: boolean;
7
7
  tone?: Tone;
@@ -40,7 +40,7 @@
40
40
  const variants = $derived({
41
41
  soft: `${toneSurface[tone]} border ${toneBorderSoft[tone]}`,
42
42
  outline: `bg-bg shadow-xs border ${toneBorderSoft[tone]}`,
43
- accent: `${toneSurface[tone]} border-l-4 ${toneBorder[tone]}`
43
+ accent: `${toneSurface[tone]} border-l-2 ${toneBorder[tone]}`
44
44
  });
45
45
 
46
46
  function dismiss() {
@@ -69,7 +69,7 @@
69
69
 
70
70
  <div class="flex min-w-0 flex-1 flex-col gap-1">
71
71
  {#if title}
72
- <p class="text-sm leading-snug font-semibold {toneText[tone]}">{title}</p>
72
+ <p class="font-heading text-sm leading-snug font-semibold {toneText[tone]}">{title}</p>
73
73
  {/if}
74
74
  {#if children}
75
75
  <div class="text-[13px] leading-relaxed text-text-secondary">{@render children()}</div>
@@ -47,6 +47,12 @@
47
47
  xl: 'grid place-items-center *:size-8'
48
48
  };
49
49
 
50
+ // A src that 404s left the reader with the browser's broken-image mark while
51
+ // the initials sat right there unused. Holding the failed URL rather than a
52
+ // flag means a new src is tried again on its own.
53
+ let failed = $state('');
54
+ const broken = $derived(failed !== '' && failed === src);
55
+
50
56
  const initials = $derived(
51
57
  name
52
58
  .trim()
@@ -60,13 +66,18 @@
60
66
 
61
67
  <span
62
68
  class="inline-flex shrink-0 items-center justify-center overflow-hidden font-sans font-semibold select-none
63
- {squared ? 'rounded-lg' : 'rounded-full'} {sizes[size]} {src
69
+ {squared ? 'rounded-lg' : 'rounded-full'} {sizes[size]} {src && !broken
64
70
  ? 'bg-bg-inset'
65
71
  : toneSoft[tone]} {className}"
66
72
  {...rest}
67
73
  >
68
- {#if src}
69
- <img {src} alt={alt ?? name} class="size-full object-cover" />
74
+ {#if src && !broken}
75
+ <img
76
+ {src}
77
+ alt={alt ?? name}
78
+ onerror={() => (failed = src ?? '')}
79
+ class="size-full object-cover"
80
+ />
70
81
  {:else if fallback}
71
82
  <span class={glyphs[size]}>{@render fallback()}</span>
72
83
  {:else}
@@ -63,7 +63,10 @@
63
63
  }: Props = $props();
64
64
 
65
65
  const base =
66
- 'inline-flex items-center justify-center gap-2 rounded-md border font-sans font-medium whitespace-nowrap transition-colors duration-150 ease-brand-out disabled:opacity-50 disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:pointer-events-none';
66
+ // The press: shadow is paper's language, so a click flattens the button
67
+ // onto the page (shadow drops) and dims it a notch. Snaps in at 75ms,
68
+ // settles back over the base 150ms on release.
69
+ 'inline-flex items-center justify-center gap-2 rounded-md border font-sans font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,filter] duration-150 ease-brand-out active:shadow-none active:brightness-95 active:duration-75 disabled:opacity-50 disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:pointer-events-none';
67
70
 
68
71
  const sizes: Record<ButtonSize, string> = {
69
72
  sm: 'h-8 px-3 text-[13px]',
@@ -107,6 +110,10 @@
107
110
  const iconClass = $derived(size === 'sm' ? iconSm : iconMd);
108
111
 
109
112
  async function handleClick(event: MouseEvent) {
113
+ if (inert) {
114
+ event.preventDefault();
115
+ return;
116
+ }
110
117
  const result = onclick?.(event);
111
118
  // Anything else is a plain handler: nothing to wait for, nothing to lock.
112
119
  if (!(result instanceof Promise)) return;
@@ -125,7 +132,7 @@
125
132
  disabled={href ? undefined : inert}
126
133
  aria-disabled={href && inert ? 'true' : undefined}
127
134
  aria-busy={busy ? 'true' : undefined}
128
- role={href ? 'button' : undefined}
135
+ tabindex={href && inert ? -1 : undefined}
129
136
  class="{base} {focusRing} {toneRing[tone]} {variantClass} {isLink
130
137
  ? ''
131
138
  : iconOnly
@@ -48,7 +48,7 @@
48
48
 
49
49
  <div
50
50
  class="flex flex-col rounded-lg {variants[variant]}
51
- {accent ? `border-l-4 ${toneBorderLeft[tone]}` : ''}
51
+ {accent ? `border-l-2 ${toneBorderLeft[tone]}` : ''}
52
52
  {interactive
53
53
  ? `transition-shadow duration-150 ease-brand-out hover:shadow-md ${toneHoverBorder[tone]}`
54
54
  : ''}
@@ -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
  >
@@ -210,7 +219,7 @@
210
219
  use:clickOutside={{ onoutside: () => (open = false), ignore: [wrapper] }}
211
220
  id="{id ?? 'combobox'}-listbox"
212
221
  role="listbox"
213
- class="z-50 max-h-64 overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg"
222
+ class="z-50 max-h-64 animate-reveal overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg"
214
223
  >
215
224
  {#if flat.length === 0}
216
225
  <p class="px-3 py-6 text-center text-sm text-text-muted">
@@ -160,7 +160,7 @@
160
160
  onclick={(event) => {
161
161
  if (event.target === dialog) open = false;
162
162
  }}
163
- class="mx-auto mt-[12vh] mb-auto w-[min(36rem,calc(100vw-2rem))] max-w-none rounded-xl border border-hairline bg-bg p-0 text-text shadow-2xl backdrop:bg-text/30 backdrop:backdrop-blur-[1px] {className}"
163
+ class="mx-auto mt-[12vh] mb-auto w-[min(36rem,calc(100vw-2rem))] max-w-none animate-enter rounded-xl border border-hairline bg-bg p-0 text-text shadow-2xl backdrop:bg-text/30 backdrop:backdrop-blur-[1px] {className}"
164
164
  >
165
165
  <div use:focusTrap={{ autofocus: false }} onkeydown={onKeydown} role="none" class="flex flex-col">
166
166
  <div class="flex items-center gap-3 border-b border-hairline px-4">
@@ -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()}
@@ -78,7 +78,7 @@
78
78
  role="menu"
79
79
  aria-label={label ?? 'Menu'}
80
80
  tabindex="-1"
81
- class="z-50 flex max-h-[min(24rem,80vh)] min-w-48 flex-col overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg {menuClass}"
81
+ class="z-50 flex max-h-[min(24rem,80vh)] min-w-48 animate-reveal flex-col overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg {menuClass}"
82
82
  >
83
83
  {@render menu()}
84
84
  </div>
@@ -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
  >
@@ -172,7 +179,7 @@
172
179
  use:anchored={{ anchor: wrapper, placement: 'bottom-start', offset: 4 }}
173
180
  use:clickOutside={{ onoutside: () => (open = false), ignore: [wrapper] }}
174
181
  id="{id ?? 'datepicker'}-calendar"
175
- class="z-50 rounded-lg border border-hairline bg-bg shadow-lg"
182
+ class="z-50 animate-reveal rounded-lg border border-hairline bg-bg shadow-lg"
176
183
  >
177
184
  <Calendar
178
185
  bind:value
@@ -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}
@@ -84,7 +84,7 @@
84
84
  role="menu"
85
85
  aria-label={label ?? 'Menu'}
86
86
  tabindex="-1"
87
- class="z-50 flex max-h-[min(24rem,80vh)] min-w-48 flex-col overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg {className}"
87
+ class="z-50 flex max-h-[min(24rem,80vh)] min-w-48 animate-reveal flex-col overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg {className}"
88
88
  >
89
89
  {@render children()}
90
90
  </div>
@@ -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
  >