@nqmcreative/ui 0.2.2 → 0.2.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 (46) hide show
  1. package/dist/core/index.d.ts +1 -1
  2. package/dist/core/index.js +1 -1
  3. package/dist/core/trigger.d.ts +6 -0
  4. package/dist/core/trigger.js +13 -0
  5. package/dist/styles/matte/Button.svelte +4 -4
  6. package/dist/styles/matte/Card.svelte +3 -3
  7. package/dist/styles/matte/Combobox.svelte +2 -2
  8. package/dist/styles/matte/CommandPalette.svelte +32 -7
  9. package/dist/styles/matte/CommandPalette.svelte.d.ts +5 -0
  10. package/dist/styles/matte/DatePicker.svelte +2 -2
  11. package/dist/styles/matte/Drawer.svelte +2 -2
  12. package/dist/styles/matte/Dropzone.svelte +1 -1
  13. package/dist/styles/matte/FeatureCard.svelte +1 -1
  14. package/dist/styles/matte/Input.svelte +4 -4
  15. package/dist/styles/matte/MenuItem.svelte +1 -1
  16. package/dist/styles/matte/Modal.svelte +3 -3
  17. package/dist/styles/matte/NumberInput.svelte +2 -2
  18. package/dist/styles/matte/PasswordInput.svelte +2 -2
  19. package/dist/styles/matte/PricingCard.svelte +3 -3
  20. package/dist/styles/matte/SegmentedControl.svelte +1 -1
  21. package/dist/styles/matte/Select.svelte +1 -1
  22. package/dist/styles/matte/Sidebar.svelte +75 -26
  23. package/dist/styles/matte/Sidebar.svelte.d.ts +15 -1
  24. package/dist/styles/matte/StatsBand.svelte +1 -1
  25. package/dist/styles/matte/Table.svelte +3 -3
  26. package/dist/styles/matte/Testimonial.svelte +2 -2
  27. package/dist/styles/matte/Textarea.svelte +1 -1
  28. package/dist/styles/matte/index.d.ts +1 -1
  29. package/dist/styles/paper/CommandPalette.svelte +25 -2
  30. package/dist/styles/paper/CommandPalette.svelte.d.ts +5 -0
  31. package/dist/styles/paper/Modal.svelte +9 -1
  32. package/dist/styles/paper/Sidebar.svelte +75 -26
  33. package/dist/styles/paper/Sidebar.svelte.d.ts +15 -1
  34. package/dist/styles/paper/fonts.css +34 -7
  35. package/dist/styles/paper/index.d.ts +1 -1
  36. package/dist/styles/paper/theme.css +11 -8
  37. package/dist/styles/sprout/CommandPalette.svelte +25 -2
  38. package/dist/styles/sprout/CommandPalette.svelte.d.ts +5 -0
  39. package/dist/styles/sprout/Modal.svelte +9 -1
  40. package/dist/styles/sprout/Sidebar.svelte +75 -26
  41. package/dist/styles/sprout/Sidebar.svelte.d.ts +15 -1
  42. package/dist/styles/sprout/fonts.css +7 -8
  43. package/dist/styles/sprout/index.d.ts +1 -1
  44. package/dist/styles/sprout/theme.css +6 -6
  45. package/package.json +1 -1
  46. package/registry.json +2 -1
@@ -15,7 +15,7 @@ export type { Placement, AnchorOptions } from './actions/anchor.js';
15
15
  export { clickOutside, focusTrap, focusable, navigateList, portal } from './actions/dismissable.js';
16
16
  export type { ClickOutsideOptions, FocusTrapOptions } from './actions/dismissable.js';
17
17
  export { syncDialog, isBackdropClick } from './dialog.svelte.js';
18
- export { popupTrigger, describeTrigger, menuItems, HOVER_GRACE } from './trigger.js';
18
+ export { popupTrigger, describeTrigger, menuItems, isTypingTarget, HOVER_GRACE } from './trigger.js';
19
19
  export type { PopupTriggerOptions } from './trigger.js';
20
20
  export { ListCursor, groupItems, matchQuery, revealIndex } from './list.svelte.js';
21
21
  export type { ListItem } from './list.svelte.js';
@@ -15,7 +15,7 @@ export { anchored } from './actions/anchor.js';
15
15
  export { clickOutside, focusTrap, focusable, navigateList, portal } from './actions/dismissable.js';
16
16
  /* --- overlays --- */
17
17
  export { syncDialog, isBackdropClick } from './dialog.svelte.js';
18
- export { popupTrigger, describeTrigger, menuItems, HOVER_GRACE } from './trigger.js';
18
+ export { popupTrigger, describeTrigger, menuItems, isTypingTarget, HOVER_GRACE } from './trigger.js';
19
19
  /* --- lists --- */
20
20
  export { ListCursor, groupItems, matchQuery, revealIndex } from './list.svelte.js';
21
21
  /* --- data --- */
@@ -33,3 +33,9 @@ export declare function popupTrigger(node: HTMLElement, options: PopupTriggerOpt
33
33
  export declare function describeTrigger(wrapper: HTMLElement | null, kind: 'menu' | 'dialog' | 'listbox', open: boolean): void;
34
34
  /** The menu items a roving-focus menu should walk, in DOM order. */
35
35
  export declare function menuItems(menu: HTMLElement | null): HTMLElement[];
36
+ /**
37
+ * Whether a keystroke landed in something the reader is typing into. A
38
+ * bare-key shortcut — `/` for search — has to stand down for those, or it
39
+ * eats the character instead of opening anything.
40
+ */
41
+ export declare function isTypingTarget(target: EventTarget | null): boolean;
@@ -75,3 +75,16 @@ export function describeTrigger(wrapper, kind, open) {
75
75
  export function menuItems(menu) {
76
76
  return menu ? [...menu.querySelectorAll('[role="menuitem"]:not([disabled])')] : [];
77
77
  }
78
+ /**
79
+ * Whether a keystroke landed in something the reader is typing into. A
80
+ * bare-key shortcut — `/` for search — has to stand down for those, or it
81
+ * eats the character instead of opening anything.
82
+ */
83
+ export function isTypingTarget(target) {
84
+ if (!(target instanceof HTMLElement))
85
+ return false;
86
+ return (target.isContentEditable ||
87
+ target.tagName === 'INPUT' ||
88
+ target.tagName === 'TEXTAREA' ||
89
+ target.tagName === 'SELECT');
90
+ }
@@ -49,10 +49,10 @@
49
49
  'inline-flex items-center justify-center gap-2 border font-sans font-medium whitespace-nowrap transition-all duration-150 ease-brand-out disabled:opacity-50 disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:pointer-events-none';
50
50
 
51
51
  const sizes: Record<ButtonSize, string> = {
52
- sm: 'h-9 px-4 text-[13px]',
53
- md: 'h-11 px-7 text-[15px]',
54
- lg: 'h-12 px-8 text-base',
55
- xl: 'h-14 px-10 text-base'
52
+ sm: 'h-8 px-3.5 text-[13px]',
53
+ md: 'h-10 px-5 text-[15px]',
54
+ lg: 'h-11 px-6 text-base',
55
+ xl: 'h-12 px-8 text-base'
56
56
  };
57
57
 
58
58
  const variantClass = $derived.by(() => {
@@ -50,7 +50,7 @@
50
50
  {...rest}
51
51
  >
52
52
  {#if header || title || eyebrow}
53
- <div class="flex flex-col gap-1 {padded ? 'px-6 pt-6' : ''}">
53
+ <div class="flex flex-col gap-1 {padded ? 'px-5 pt-5' : ''}">
54
54
  {#if header}
55
55
  {@render header()}
56
56
  {:else}
@@ -64,12 +64,12 @@
64
64
  </div>
65
65
  {/if}
66
66
 
67
- <div class="flex-1 {padded ? (header || title || eyebrow ? 'px-6 pt-4 pb-6' : 'p-6') : ''}">
67
+ <div class="flex-1 {padded ? (header || title || eyebrow ? 'px-5 pt-3.5 pb-5' : 'p-5') : ''}">
68
68
  {@render children()}
69
69
  </div>
70
70
 
71
71
  {#if footer}
72
- <div class="border-t border-hairline {padded ? 'px-6 py-4' : ''}">
72
+ <div class="border-t border-hairline {padded ? 'px-5 py-3.5' : ''}">
73
73
  {@render footer()}
74
74
  </div>
75
75
  {/if}
@@ -142,7 +142,7 @@
142
142
 
143
143
  <div
144
144
  bind:this={wrapper}
145
- class="relative inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-4 transition-colors duration-150 ease-brand-out
145
+ class="relative inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-3.5 transition-colors duration-150 ease-brand-out
146
146
  {invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
147
147
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
148
148
  >
@@ -166,7 +166,7 @@
166
166
  }}
167
167
  onfocus={openList}
168
168
  onkeydown={onKeydown}
169
- class="h-11 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
169
+ class="h-10 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
170
170
  />
171
171
 
172
172
  {#if name}<input type="hidden" {name} {value} />{/if}
@@ -21,6 +21,7 @@
21
21
  import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
22
22
  import { useLocale } from '../../core/locale.svelte.js';
23
23
  import { toneSoft, type Tone } from '../../core/tones.js';
24
+ import { isTypingTarget } from '../../core/trigger.js';
24
25
  import Kbd from './Kbd.svelte';
25
26
 
26
27
  interface Props {
@@ -34,6 +35,11 @@
34
35
  * Pass `null` to wire your own trigger only.
35
36
  */
36
37
  hotkey?: string | null;
38
+ /**
39
+ * Key that opens the palette on its own, pressed anywhere the reader
40
+ * isn't typing. Default `'/'`. Pass `null` to turn it off.
41
+ */
42
+ quickKey?: string | null;
37
43
  onselect?: (item: CommandItem) => void;
38
44
  class?: string;
39
45
  }
@@ -45,6 +51,7 @@
45
51
  emptyText,
46
52
  tone = 'brand',
47
53
  hotkey = 'k',
54
+ quickKey = '/',
48
55
  onselect,
49
56
  class: className = ''
50
57
  }: Props = $props();
@@ -82,11 +89,27 @@
82
89
  });
83
90
 
84
91
  $effect(() => {
85
- if (!hotkey) return;
92
+ if (!hotkey && !quickKey) return;
86
93
  const onKeydown = (event: KeyboardEvent) => {
87
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === hotkey.toLowerCase()) {
94
+ const key = event.key.toLowerCase();
95
+
96
+ if ((event.metaKey || event.ctrlKey) && key === hotkey?.toLowerCase()) {
88
97
  event.preventDefault();
89
98
  open = !open;
99
+ return;
100
+ }
101
+
102
+ // A bare key can only open the palette, never close it: while it is
103
+ // open the keystroke belongs to the query field.
104
+ if (
105
+ key === quickKey?.toLowerCase() &&
106
+ !event.metaKey &&
107
+ !event.ctrlKey &&
108
+ !event.altKey &&
109
+ !isTypingTarget(event.target)
110
+ ) {
111
+ event.preventDefault();
112
+ open = true;
90
113
  }
91
114
  };
92
115
  window.addEventListener('keydown', onKeydown);
@@ -138,7 +161,7 @@
138
161
  class="mx-auto mt-[12vh] mb-auto w-[min(36rem,calc(100vw-2rem))] max-w-none border border-hairline bg-bg p-0 text-text backdrop:bg-text/40 {className}"
139
162
  >
140
163
  <div use:focusTrap={{ autofocus: false }} onkeydown={onKeydown} role="none" class="flex flex-col">
141
- <div class="flex items-center gap-3 border-b border-hairline px-4">
164
+ <div class="flex items-center gap-3 border-b border-hairline px-3.5">
142
165
  <svg
143
166
  class="size-4 shrink-0 text-text-muted"
144
167
  viewBox="0 0 16 16"
@@ -156,7 +179,7 @@
156
179
  autocomplete="off"
157
180
  aria-label={placeholder ?? t.current.commandPlaceholder}
158
181
  placeholder={placeholder ?? t.current.commandPlaceholder}
159
- class="h-12 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
182
+ class="h-11 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
160
183
  />
161
184
  <Kbd>esc</Kbd>
162
185
  </div>
@@ -168,13 +191,15 @@
168
191
  class="max-h-80 overflow-y-auto py-1"
169
192
  >
170
193
  {#if flat.length === 0}
171
- <p class="px-4 py-10 text-center font-sans text-sm text-text-muted">
194
+ <p class="px-3.5 py-10 text-center font-sans text-sm text-text-muted">
172
195
  {emptyText ?? t.current.noResults}
173
196
  </p>
174
197
  {:else}
175
198
  {#each groups as [group, groupItems] (group)}
176
199
  {#if group}
177
- <p class="px-4 pt-3 pb-1 font-mono text-[10px] tracking-wide text-text-muted uppercase">
200
+ <p
201
+ class="px-3.5 pt-3 pb-1 font-mono text-[10px] tracking-wide text-text-muted uppercase"
202
+ >
178
203
  {group}
179
204
  </p>
180
205
  {/if}
@@ -189,7 +214,7 @@
189
214
  aria-disabled={item.disabled}
190
215
  onclick={() => run(item)}
191
216
  onpointermove={() => (cursor.index = index)}
192
- class="flex cursor-pointer items-center gap-3 px-4 py-2.5 font-sans text-[14px] transition-colors duration-100
217
+ class="flex cursor-pointer items-center gap-3 px-3.5 py-2 font-sans text-[14px] transition-colors duration-100
193
218
  {item.disabled ? 'pointer-events-none opacity-40' : ''}
194
219
  {index === active ? toneSoft[tone] : 'text-text-secondary'}"
195
220
  >
@@ -24,6 +24,11 @@ interface Props {
24
24
  * Pass `null` to wire your own trigger only.
25
25
  */
26
26
  hotkey?: string | null;
27
+ /**
28
+ * Key that opens the palette on its own, pressed anywhere the reader
29
+ * isn't typing. Default `'/'`. Pass `null` to turn it off.
30
+ */
31
+ quickKey?: string | null;
27
32
  onselect?: (item: CommandItem) => void;
28
33
  class?: string;
29
34
  }
@@ -94,7 +94,7 @@
94
94
 
95
95
  <div
96
96
  bind:this={wrapper}
97
- class="inline-flex w-full items-center gap-1 border bg-bg pr-1 pl-4 transition-colors duration-150 ease-brand-out
97
+ class="inline-flex w-full items-center gap-1 border bg-bg pr-1 pl-3.5 transition-colors duration-150 ease-brand-out
98
98
  {invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
99
99
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
100
100
  >
@@ -118,7 +118,7 @@
118
118
  }}
119
119
  onblur={commit}
120
120
  onkeydown={onKeydown}
121
- class="h-11 w-full min-w-0 bg-transparent font-sans text-[15px] text-text tabular-nums placeholder:text-text-muted focus:outline-none"
121
+ class="h-10 w-full min-w-0 bg-transparent font-sans text-[15px] text-text tabular-nums placeholder:text-text-muted focus:outline-none"
122
122
  />
123
123
 
124
124
  {#if name}<input type="hidden" {name} {value} />{/if}
@@ -91,7 +91,7 @@
91
91
  >
92
92
  <div class="flex h-full flex-col">
93
93
  {#if title || dismissible}
94
- <div class="flex items-start gap-4 border-b border-hairline px-6 py-5">
94
+ <div class="flex items-start gap-4 border-b border-hairline px-5 py-4">
95
95
  <div class="flex min-w-0 flex-1 flex-col gap-1">
96
96
  {#if title}
97
97
  <h2 class="font-heading text-lg font-medium tracking-tight">{title}</h2>
@@ -116,7 +116,7 @@
116
116
  {/if}
117
117
 
118
118
  <div
119
- class="flex-1 overflow-y-auto px-6 py-5 font-sans text-[15px] leading-relaxed text-text-secondary"
119
+ class="flex-1 overflow-y-auto px-5 py-4 font-sans text-[15px] leading-relaxed text-text-secondary"
120
120
  >
121
121
  {@render children()}
122
122
  </div>
@@ -173,7 +173,7 @@
173
173
  <ul class="flex flex-col border border-hairline">
174
174
  {#each files as file, i (file.name + file.size + i)}
175
175
  <li
176
- class="flex items-center gap-3 border-b border-hairline px-4 py-3 font-sans last:border-b-0"
176
+ class="flex items-center gap-3 border-b border-hairline px-3.5 py-2.5 font-sans last:border-b-0"
177
177
  >
178
178
  <span class="min-w-0 flex-1 truncate text-sm text-text">{file.name}</span>
179
179
  <span class="shrink-0 font-mono text-xs text-text-muted">{formatSize(file.size)}</span>
@@ -29,7 +29,7 @@
29
29
  <svelte:element
30
30
  this={href ? 'a' : 'div'}
31
31
  {href}
32
- class="group flex flex-col gap-3 bg-bg p-6 {href
32
+ class="group flex flex-col gap-3 bg-bg p-5 {href
33
33
  ? `transition-colors duration-150 ease-brand-out ${focusRing} ${toneRing[tone]}`
34
34
  : ''} {className}"
35
35
  {...rest}
@@ -30,14 +30,14 @@
30
30
  }: Props = $props();
31
31
 
32
32
  const sizes: Record<InputSize, string> = {
33
- sm: 'h-9 text-[13px]',
34
- md: 'h-11 text-[15px]',
35
- lg: 'h-12 text-base'
33
+ sm: 'h-8 text-[13px]',
34
+ md: 'h-10 text-[15px]',
35
+ lg: 'h-11 text-base'
36
36
  };
37
37
  </script>
38
38
 
39
39
  <span
40
- class="inline-flex w-full items-center gap-2 border bg-bg px-4 transition-colors duration-150 ease-brand-out
40
+ class="inline-flex w-full items-center gap-2 border bg-bg px-3.5 transition-colors duration-150 ease-brand-out
41
41
  {sizes[size]}
42
42
  {invalid
43
43
  ? 'border-danger focus-within:border-danger'
@@ -37,7 +37,7 @@
37
37
  disabled={href ? undefined : disabled}
38
38
  aria-disabled={disabled ? 'true' : undefined}
39
39
  tabindex={disabled ? undefined : -1}
40
- class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-[14px] transition-colors duration-100 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-40
40
+ class="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[14px] transition-colors duration-100 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-40
41
41
  {tone === 'neutral'
42
42
  ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
43
43
  : `${toneText[tone]} ${toneSoftHover[tone]}`}
@@ -74,7 +74,7 @@
74
74
  >
75
75
  <div class="flex flex-col">
76
76
  {#if title || closable}
77
- <div class="flex items-start gap-4 border-b border-hairline px-6 py-5">
77
+ <div class="flex items-start gap-4 border-b border-hairline px-5 py-4">
78
78
  <div class="flex min-w-0 flex-1 flex-col gap-1">
79
79
  {#if title}
80
80
  <h2 class="font-heading text-lg font-medium tracking-tight">{title}</h2>
@@ -98,12 +98,12 @@
98
98
  </div>
99
99
  {/if}
100
100
 
101
- <div class="px-6 py-5 font-sans text-[15px] leading-relaxed text-text-secondary">
101
+ <div class="px-5 py-4 font-sans text-[15px] leading-relaxed text-text-secondary">
102
102
  {@render children()}
103
103
  </div>
104
104
 
105
105
  {#if footer}
106
- <div class="flex items-center justify-end gap-3 border-t border-hairline px-6 py-4">
106
+ <div class="flex items-center justify-end gap-3 border-t border-hairline px-5 py-3.5">
107
107
  {@render footer()}
108
108
  </div>
109
109
  {/if}
@@ -51,7 +51,7 @@
51
51
  </script>
52
52
 
53
53
  <span
54
- class="inline-flex w-full items-center border bg-bg pr-1 pl-4 transition-colors duration-150 ease-brand-out
54
+ class="inline-flex w-full items-center border bg-bg pr-1 pl-3.5 transition-colors duration-150 ease-brand-out
55
55
  {invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
56
56
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
57
57
  >
@@ -65,7 +65,7 @@
65
65
  oninput={(event) => (value = clamp(event.currentTarget.valueAsNumber))}
66
66
  onkeydown={onKeydown}
67
67
  aria-invalid={invalid ? 'true' : undefined}
68
- class="h-11 w-full min-w-0 [appearance:textfield] bg-transparent font-sans text-[15px] text-text tabular-nums placeholder:text-text-muted focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
68
+ class="h-10 w-full min-w-0 [appearance:textfield] bg-transparent font-sans text-[15px] text-text tabular-nums placeholder:text-text-muted focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
69
69
  {...rest}
70
70
  />
71
71
  {#if unit}
@@ -34,7 +34,7 @@
34
34
 
35
35
  <div class="flex w-full flex-col gap-2">
36
36
  <span
37
- class="inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-4 transition-colors duration-150 ease-brand-out
37
+ class="inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-3.5 transition-colors duration-150 ease-brand-out
38
38
  {invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
39
39
  {disabled ? 'pointer-events-none opacity-50' : ''} {className}"
40
40
  >
@@ -43,7 +43,7 @@
43
43
  {disabled}
44
44
  type={visible ? 'text' : 'password'}
45
45
  aria-invalid={invalid ? 'true' : undefined}
46
- class="h-11 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
46
+ class="h-10 w-full min-w-0 bg-transparent font-sans text-[15px] text-text placeholder:text-text-muted focus:outline-none"
47
47
  {...rest}
48
48
  />
49
49
  <button
@@ -45,7 +45,7 @@
45
45
  : 'border border-hairline'} {className}"
46
46
  {...rest}
47
47
  >
48
- <div class="flex flex-col gap-4 p-6 {featured ? toneSoft[tone] : ''}">
48
+ <div class="flex flex-col gap-4 p-5 {featured ? toneSoft[tone] : ''}">
49
49
  <div class="flex items-start justify-between gap-3">
50
50
  <h3 class="font-heading text-lg font-medium tracking-tight {featured ? '' : 'text-text'}">
51
51
  {name}
@@ -80,7 +80,7 @@
80
80
  </div>
81
81
 
82
82
  {#if features.length}
83
- <ul class="flex flex-1 flex-col gap-3 border-t border-hairline p-6">
83
+ <ul class="flex flex-1 flex-col gap-3 border-t border-hairline p-5">
84
84
  {#each features as feature (feature)}
85
85
  <li class="flex items-start gap-3 font-sans text-sm text-text-secondary">
86
86
  <svg
@@ -102,6 +102,6 @@
102
102
  {/if}
103
103
 
104
104
  {#if action}
105
- <div class="p-6 pt-2">{@render action()}</div>
105
+ <div class="p-5 pt-2">{@render action()}</div>
106
106
  {/if}
107
107
  </div>
@@ -41,7 +41,7 @@
41
41
 
42
42
  const sizes: Record<SegmentedSize, string> = {
43
43
  sm: 'h-8 px-3 text-[13px]',
44
- md: 'h-10 px-4 text-sm'
44
+ md: 'h-9 px-3.5 text-sm'
45
45
  };
46
46
 
47
47
  function select(option: SegmentOption) {
@@ -35,7 +35,7 @@
35
35
  <select
36
36
  bind:value
37
37
  aria-invalid={invalid ? 'true' : undefined}
38
- class="h-11 w-full appearance-none border bg-bg py-0 pr-10 pl-4 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-50
38
+ class="h-10 w-full appearance-none border bg-bg py-0 pr-10 pl-3.5 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-50
39
39
  {invalid ? 'border-danger' : `border-hairline ${toneFocusBorder[tone]}`} {className}"
40
40
  {...rest}
41
41
  >
@@ -1,4 +1,8 @@
1
1
  <script module lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export type SidebarVariant = 'plain' | 'filled' | 'floating';
5
+
2
6
  export interface SidebarItem {
3
7
  /** Stable id used for the active state. Falls back to `href`. */
4
8
  id?: string;
@@ -6,10 +10,21 @@
6
10
  href?: string;
7
11
  disabled?: boolean;
8
12
  badge?: string | number;
13
+ /**
14
+ * Leading glyph, 16px. The one thing a collapsed rail still shows, so
15
+ * an item that has to survive `collapsed` needs one.
16
+ */
17
+ icon?: Snippet;
9
18
  /** Nested links — the parent becomes a collapsible group. */
10
19
  items?: SidebarItem[];
11
20
  /** Open the group on first render. */
12
21
  open?: boolean;
22
+ /**
23
+ * `false` pins a group open: the parent stops being a button and the
24
+ * children are always on show. For navigation a reader should not be
25
+ * able to lose.
26
+ */
27
+ collapsible?: boolean;
13
28
  }
14
29
 
15
30
  export interface SidebarSection {
@@ -20,7 +35,6 @@
20
35
  </script>
21
36
 
22
37
  <script lang="ts">
23
- import type { Snippet } from 'svelte';
24
38
  import { useLocale } from '../../core/locale.svelte.js';
25
39
  import { focusRing, toneRing, toneSoft, type Tone } from '../../core/tones.js';
26
40
 
@@ -32,6 +46,8 @@
32
46
  collapsed?: boolean;
33
47
  /** Show the collapse button. */
34
48
  collapsible?: boolean;
49
+ /** Surface treatment: bare, the framed default, or a detached card. */
50
+ variant?: SidebarVariant;
35
51
  tone?: Tone;
36
52
  header?: Snippet;
37
53
  footer?: Snippet;
@@ -44,6 +60,7 @@
44
60
  value = $bindable(''),
45
61
  collapsed = $bindable(false),
46
62
  collapsible = true,
63
+ variant = 'filled',
47
64
  tone = 'brand',
48
65
  header,
49
66
  footer,
@@ -59,7 +76,8 @@
59
76
  let opened = $state<Record<string, boolean>>({});
60
77
 
61
78
  const isOpen = (item: SidebarItem) =>
62
- opened[keyOf(item)] ?? (item.open || item.items!.some((child) => keyOf(child) === value));
79
+ item.collapsible === false ||
80
+ (opened[keyOf(item)] ?? (item.open || item.items!.some((child) => keyOf(child) === value)));
63
81
 
64
82
  function toggle(item: SidebarItem) {
65
83
  opened = { ...opened, [keyOf(item)]: !isOpen(item) };
@@ -71,13 +89,50 @@
71
89
  onnavigate?.(item);
72
90
  }
73
91
 
92
+ const surfaces: Record<SidebarVariant, string> = {
93
+ plain: 'h-full bg-transparent',
94
+ filled: 'h-full border-r border-hairline bg-bg',
95
+ floating: 'm-3 h-[calc(100%-1.5rem)] border border-hairline bg-bg'
96
+ };
97
+
74
98
  const row =
75
99
  'flex w-full items-center gap-3 px-3 py-2 text-left font-sans text-sm transition-colors duration-150 ease-brand-out disabled:pointer-events-none disabled:opacity-40';
76
100
  </script>
77
101
 
102
+ <!-- Nested links. One copy, rendered by both kinds of group. -->
103
+ {#snippet subitems(item: SidebarItem)}
104
+ <div class="ml-5 flex flex-col gap-0.5 border-l border-hairline pl-1">
105
+ {#each item.items as child (keyOf(child))}
106
+ <a
107
+ href={child.href}
108
+ onclick={() => go(child)}
109
+ aria-current={value === keyOf(child) ? 'page' : undefined}
110
+ class="{row} {focusRing} {toneRing[tone]} {value === keyOf(child)
111
+ ? `${toneSoft[tone]} font-medium`
112
+ : 'text-text-muted hover:bg-bg-inset hover:text-text'} {child.disabled
113
+ ? 'pointer-events-none opacity-40'
114
+ : ''}"
115
+ >
116
+ {@render glyph(child)}
117
+ <span class="min-w-0 flex-1 truncate">{child.label}</span>
118
+ {#if child.badge !== undefined}
119
+ <span class="shrink-0 font-mono text-[11px]">{child.badge}</span>
120
+ {/if}
121
+ </a>
122
+ {/each}
123
+ </div>
124
+ {/snippet}
125
+
126
+ <!-- One box for every glyph, so rows line up whether or not an item has one. -->
127
+ {#snippet glyph(item: SidebarItem)}
128
+ {#if item.icon}
129
+ <span class="grid size-4 shrink-0 place-items-center *:size-4">{@render item.icon()}</span>
130
+ {/if}
131
+ {/snippet}
132
+
78
133
  <aside
79
- class="flex h-full shrink-0 flex-col border-r border-hairline bg-bg transition-[width] duration-200 ease-brand-out
80
- {collapsed ? 'w-16' : 'w-60'} {className}"
134
+ class="flex shrink-0 flex-col transition-[width] duration-200 ease-brand-out
135
+ {surfaces[variant]} {collapsed ? 'w-16' : 'w-60'} {className}"
81
136
  >
82
137
  {#if header}
83
138
  <div class="flex h-16 shrink-0 items-center gap-3 border-b border-hairline px-3">
@@ -97,16 +152,25 @@
97
152
  {/if}
98
153
 
99
154
  {#each section.items as item (keyOf(item))}
100
- {#if item.items?.length}
155
+ {#if item.items?.length && item.collapsible === false}
156
+ <div class="{row} {collapsed ? 'justify-center' : ''} font-medium text-text-muted">
157
+ {@render glyph(item)}
158
+ <span class="min-w-0 flex-1 truncate {collapsed ? 'sr-only' : ''}">{item.label}</span>
159
+ </div>
160
+ {#if isOpen(item) && !collapsed}
161
+ {@render subitems(item)}
162
+ {/if}
163
+ {:else if item.items?.length}
101
164
  <button
102
165
  type="button"
103
166
  onclick={() => toggle(item)}
104
167
  aria-expanded={isOpen(item)}
105
168
  title={collapsed ? item.label : undefined}
106
- class="{row} {focusRing} {toneRing[
169
+ class="{row} {collapsed ? 'justify-center' : ''} {focusRing} {toneRing[
107
170
  tone
108
171
  ]} text-text-secondary hover:bg-bg-inset hover:text-text"
109
172
  >
173
+ {@render glyph(item)}
110
174
  <span class="min-w-0 flex-1 truncate {collapsed ? 'sr-only' : ''}">{item.label}</span>
111
175
  {#if !collapsed}
112
176
  <svg
@@ -128,25 +192,7 @@
128
192
  </button>
129
193
 
130
194
  {#if isOpen(item) && !collapsed}
131
- <div class="ml-5 flex flex-col gap-0.5 border-l border-hairline pl-1">
132
- {#each item.items as child (keyOf(child))}
133
- <a
134
- href={child.href}
135
- onclick={() => go(child)}
136
- aria-current={value === keyOf(child) ? 'page' : undefined}
137
- class="{row} {focusRing} {toneRing[tone]} {value === keyOf(child)
138
- ? `${toneSoft[tone]} font-medium`
139
- : 'text-text-muted hover:bg-bg-inset hover:text-text'} {child.disabled
140
- ? 'pointer-events-none opacity-40'
141
- : ''}"
142
- >
143
- <span class="min-w-0 flex-1 truncate">{child.label}</span>
144
- {#if child.badge !== undefined}
145
- <span class="shrink-0 font-mono text-[11px]">{child.badge}</span>
146
- {/if}
147
- </a>
148
- {/each}
149
- </div>
195
+ {@render subitems(item)}
150
196
  {/if}
151
197
  {:else}
152
198
  <a
@@ -154,12 +200,15 @@
154
200
  onclick={() => go(item)}
155
201
  aria-current={value === keyOf(item) ? 'page' : undefined}
156
202
  title={collapsed ? item.label : undefined}
157
- class="{row} {focusRing} {toneRing[tone]} {value === keyOf(item)
203
+ class="{row} {collapsed ? 'justify-center' : ''} {focusRing} {toneRing[
204
+ tone
205
+ ]} {value === keyOf(item)
158
206
  ? `${toneSoft[tone]} font-medium`
159
207
  : 'text-text-secondary hover:bg-bg-inset hover:text-text'} {item.disabled
160
208
  ? 'pointer-events-none opacity-40'
161
209
  : ''}"
162
210
  >
211
+ {@render glyph(item)}
163
212
  <span class="min-w-0 flex-1 truncate {collapsed ? 'sr-only' : ''}">{item.label}</span>
164
213
  {#if item.badge !== undefined && !collapsed}
165
214
  <span class="shrink-0 font-mono text-[11px] text-text-muted">{item.badge}</span>
@@ -1,3 +1,5 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type SidebarVariant = 'plain' | 'filled' | 'floating';
1
3
  export interface SidebarItem {
2
4
  /** Stable id used for the active state. Falls back to `href`. */
3
5
  id?: string;
@@ -5,17 +7,27 @@ export interface SidebarItem {
5
7
  href?: string;
6
8
  disabled?: boolean;
7
9
  badge?: string | number;
10
+ /**
11
+ * Leading glyph, 16px. The one thing a collapsed rail still shows, so
12
+ * an item that has to survive `collapsed` needs one.
13
+ */
14
+ icon?: Snippet;
8
15
  /** Nested links — the parent becomes a collapsible group. */
9
16
  items?: SidebarItem[];
10
17
  /** Open the group on first render. */
11
18
  open?: boolean;
19
+ /**
20
+ * `false` pins a group open: the parent stops being a button and the
21
+ * children are always on show. For navigation a reader should not be
22
+ * able to lose.
23
+ */
24
+ collapsible?: boolean;
12
25
  }
13
26
  export interface SidebarSection {
14
27
  /** Small heading above a run of items. */
15
28
  label?: string;
16
29
  items: SidebarItem[];
17
30
  }
18
- import type { Snippet } from 'svelte';
19
31
  import { type Tone } from '../../core/tones.js';
20
32
  interface Props {
21
33
  sections: SidebarSection[];
@@ -25,6 +37,8 @@ interface Props {
25
37
  collapsed?: boolean;
26
38
  /** Show the collapse button. */
27
39
  collapsible?: boolean;
40
+ /** Surface treatment: bare, the framed default, or a detached card. */
41
+ variant?: SidebarVariant;
28
42
  tone?: Tone;
29
43
  header?: Snippet;
30
44
  footer?: Snippet;