@dorsk/tsumikit 0.1.1 → 0.2.1

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 (51) hide show
  1. package/README.md +4 -2
  2. package/dist/clipboard.d.ts +1 -0
  3. package/dist/clipboard.js +30 -0
  4. package/dist/components/atoms/Badge.svelte +65 -3
  5. package/dist/components/atoms/Badge.svelte.d.ts +4 -0
  6. package/dist/components/atoms/Button.svelte +80 -8
  7. package/dist/components/atoms/Button.svelte.d.ts +4 -0
  8. package/dist/components/atoms/Checkbox.svelte +7 -1
  9. package/dist/components/atoms/Checkbox.svelte.d.ts +2 -0
  10. package/dist/components/atoms/Dot.svelte +67 -0
  11. package/dist/components/atoms/Dot.svelte.d.ts +12 -0
  12. package/dist/components/atoms/Input.svelte +28 -2
  13. package/dist/components/atoms/Input.svelte.d.ts +5 -1
  14. package/dist/components/atoms/Select.svelte +9 -2
  15. package/dist/components/atoms/Select.svelte.d.ts +2 -0
  16. package/dist/components/atoms/Slider.svelte +5 -4
  17. package/dist/components/atoms/Switch.svelte +6 -1
  18. package/dist/components/atoms/Switch.svelte.d.ts +1 -0
  19. package/dist/components/atoms/Text.svelte +7 -0
  20. package/dist/components/atoms/Textarea.svelte +26 -1
  21. package/dist/components/atoms/Textarea.svelte.d.ts +4 -0
  22. package/dist/components/layouts/AppShell.svelte +15 -8
  23. package/dist/components/layouts/AutoGrid.svelte +32 -2
  24. package/dist/components/layouts/AutoGrid.svelte.d.ts +1 -0
  25. package/dist/components/molecules/Accordion.svelte +6 -3
  26. package/dist/components/molecules/CopyButton.svelte +2 -26
  27. package/dist/components/molecules/FileButton.svelte +45 -3
  28. package/dist/components/molecules/IconButton.svelte +23 -3
  29. package/dist/components/molecules/IconButton.svelte.d.ts +2 -0
  30. package/dist/components/molecules/Modal.svelte +15 -4
  31. package/dist/components/molecules/Modal.svelte.d.ts +3 -0
  32. package/dist/components/molecules/OptionButton.svelte +28 -30
  33. package/dist/components/molecules/Popover.svelte +46 -25
  34. package/dist/components/molecules/Popover.svelte.d.ts +7 -2
  35. package/dist/components/molecules/SelectButton.svelte +20 -16
  36. package/dist/components/molecules/Tabs.svelte +26 -7
  37. package/dist/components/molecules/Tabs.svelte.d.ts +2 -0
  38. package/dist/components/molecules/Toggle.svelte +30 -15
  39. package/dist/components/molecules/Tooltip.svelte +41 -28
  40. package/dist/components/molecules/Tooltip.svelte.d.ts +1 -1
  41. package/dist/components/organisms/DataTable.svelte +85 -4
  42. package/dist/components/organisms/DataTable.svelte.d.ts +6 -0
  43. package/dist/floating.d.ts +10 -0
  44. package/dist/floating.js +56 -0
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.js +2 -1
  47. package/dist/styles/app.css +14 -234
  48. package/dist/styles/variables.css +1 -1
  49. package/package.json +2 -1
  50. package/dist/components/atoms/Chip.svelte +0 -53
  51. package/dist/components/atoms/Chip.svelte.d.ts +0 -11
@@ -11,6 +11,10 @@
11
11
  type Props = HTMLTextareaAttributes & {
12
12
  mono?: boolean;
13
13
  autoresize?: boolean;
14
+ size?: 'sm' | 'md';
15
+ /** Error state: danger border + aria-invalid (also styles if a consumer
16
+ * sets aria-invalid directly). */
17
+ invalid?: boolean;
14
18
  class?: string;
15
19
  value?: HTMLTextareaAttributes['value'];
16
20
  el?: HTMLTextAreaElement | null;
@@ -19,6 +23,8 @@
19
23
  let {
20
24
  mono = false,
21
25
  autoresize = false,
26
+ size = 'md',
27
+ invalid = false,
22
28
  class: klass = '',
23
29
  value = $bindable(),
24
30
  el = $bindable(null),
@@ -31,12 +37,22 @@
31
37
  bind:this={el}
32
38
  class="textarea {klass}"
33
39
  class:mono
40
+ class:textarea-sm={size === 'sm'}
34
41
  bind:value
35
42
  use:autoresizeAction={typeof value === 'string' ? value : ''}
36
43
  {...rest}
44
+ aria-invalid={invalid || undefined}
37
45
  ></textarea>
38
46
  {:else}
39
- <textarea bind:this={el} class="textarea {klass}" class:mono bind:value {...rest}></textarea>
47
+ <textarea
48
+ bind:this={el}
49
+ class="textarea {klass}"
50
+ class:mono
51
+ class:textarea-sm={size === 'sm'}
52
+ bind:value
53
+ {...rest}
54
+ aria-invalid={invalid || undefined}
55
+ ></textarea>
40
56
  {/if}
41
57
 
42
58
  <style>
@@ -56,6 +72,15 @@
56
72
  outline: none;
57
73
  border-color: var(--accent);
58
74
  }
75
+ .textarea-sm {
76
+ padding: var(--sp-2);
77
+ font-size: var(--fs-sm);
78
+ min-height: 4rem;
79
+ }
80
+ .textarea[aria-invalid='true'],
81
+ .textarea[aria-invalid='true']:focus {
82
+ border-color: var(--danger);
83
+ }
59
84
  .mono {
60
85
  font-family: var(--font-mono);
61
86
  }
@@ -2,6 +2,10 @@ import type { HTMLTextareaAttributes } from 'svelte/elements';
2
2
  type Props = HTMLTextareaAttributes & {
3
3
  mono?: boolean;
4
4
  autoresize?: boolean;
5
+ size?: 'sm' | 'md';
6
+ /** Error state: danger border + aria-invalid (also styles if a consumer
7
+ * sets aria-invalid directly). */
8
+ invalid?: boolean;
5
9
  class?: string;
6
10
  value?: HTMLTextareaAttributes['value'];
7
11
  el?: HTMLTextAreaElement | null;
@@ -115,13 +115,16 @@
115
115
  <div class="shell" class:dragging style="--shell-sidebar-w: {widthCss}">
116
116
  <header class="shell-header">
117
117
  {#if sidebar}
118
- <IconButton
119
- class="shell-menu-btn"
120
- icon="menu"
121
- label="Toggle navigation"
122
- aria-expanded={open}
123
- onclick={() => (open = !open)}
124
- />
118
+ <!-- Wrapper owned here so the responsive hide is a scoped rule on our own
119
+ element, not a :global reach into the IconButton's button. -->
120
+ <div class="shell-menu-btn">
121
+ <IconButton
122
+ icon="menu"
123
+ label="Toggle navigation"
124
+ aria-expanded={open}
125
+ onclick={() => (open = !open)}
126
+ />
127
+ </div>
125
128
  {/if}
126
129
  {@render header?.()}
127
130
  </header>
@@ -191,6 +194,10 @@
191
194
  backdrop-filter: blur(8px);
192
195
  border-bottom: 1px solid var(--border);
193
196
  }
197
+ .shell-menu-btn {
198
+ display: inline-flex;
199
+ align-items: center;
200
+ }
194
201
  .shell-main {
195
202
  grid-area: main;
196
203
  min-width: 0; /* let content shrink instead of overflowing the grid */
@@ -272,7 +279,7 @@
272
279
  border-right: 1px solid var(--border);
273
280
  }
274
281
  .shell-scrim,
275
- :global(.shell-menu-btn) {
282
+ .shell-menu-btn {
276
283
  display: none !important;
277
284
  }
278
285
  .shell-sidebar-resize {
@@ -4,13 +4,16 @@
4
4
  // adapts to whatever space it's given (including inside a narrow column). This
5
5
  // is the "columns just adapt to available space" layout from the AppShell
6
6
  // demo, as a named component. `min` is the minimum column width, `gap` the
7
- // gutter.
7
+ // gutter. `maxCols` optionally caps how many columns the grid will ever show:
8
+ // it raises the effective per-column minimum to the width N columns would
9
+ // need, so auto-fit can never pack more than N across.
8
10
  import type { Snippet } from 'svelte';
9
11
 
10
12
  let {
11
13
  as = 'div',
12
14
  min = '14rem',
13
15
  gap = 'var(--sp-4)',
16
+ maxCols,
14
17
  class: klass = '',
15
18
  children,
16
19
  ...rest
@@ -18,13 +21,26 @@
18
21
  as?: 'div' | 'section' | 'ul' | 'ol';
19
22
  min?: string;
20
23
  gap?: string;
24
+ maxCols?: number;
21
25
  class?: string;
22
26
  children?: Snippet;
23
27
  [key: string]: unknown;
24
28
  } = $props();
29
+
30
+ let style = $derived(
31
+ maxCols != null
32
+ ? `--ag-min: ${min}; --ag-gap: ${gap}; --ag-cols: ${maxCols}; gap: ${gap}`
33
+ : `--ag-min: ${min}; gap: ${gap}`
34
+ );
25
35
  </script>
26
36
 
27
- <svelte:element this={as} class="autogrid-c {klass}" style="--ag-min: {min}; gap: {gap}" {...rest}>
37
+ <svelte:element
38
+ this={as}
39
+ class="autogrid-c {klass}"
40
+ class:capped={maxCols != null}
41
+ {style}
42
+ {...rest}
43
+ >
28
44
  {@render children?.()}
29
45
  </svelte:element>
30
46
 
@@ -33,4 +49,18 @@
33
49
  display: grid;
34
50
  grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ag-min)), 1fr));
35
51
  }
52
+
53
+ /* Cap at N columns: the per-column floor becomes the larger of `min` and the
54
+ width each column gets when N share the row (minus the N-1 gaps), so
55
+ auto-fit stops adding tracks once N fit. `min(100%, …)` keeps it from
56
+ overflowing when the container is narrower than a single `min` track. */
57
+ .autogrid-c.capped {
58
+ grid-template-columns: repeat(
59
+ auto-fit,
60
+ minmax(
61
+ min(100%, max(var(--ag-min), (100% - (var(--ag-cols) - 1) * var(--ag-gap)) / var(--ag-cols))),
62
+ 1fr
63
+ )
64
+ );
65
+ }
36
66
  </style>
@@ -3,6 +3,7 @@ type $$ComponentProps = {
3
3
  as?: 'div' | 'section' | 'ul' | 'ol';
4
4
  min?: string;
5
5
  gap?: string;
6
+ maxCols?: number;
6
7
  class?: string;
7
8
  children?: Snippet;
8
9
  [key: string]: unknown;
@@ -37,7 +37,7 @@
37
37
  <details name={groupName} open={item.open}>
38
38
  <summary>
39
39
  <span class="acc-title">{item.title}</span>
40
- <Icon name="chevron-down" />
40
+ <span class="acc-chevron"><Icon name="chevron-down" /></span>
41
41
  </summary>
42
42
  <div class="acc-panel">{@render item.content()}</div>
43
43
  </details>
@@ -79,11 +79,14 @@
79
79
  outline: 2px solid var(--accent);
80
80
  outline-offset: -2px;
81
81
  }
82
- summary :global(.icon) {
82
+ /* Wrap the chevron in our own element so rotation is a scoped rule (no
83
+ :global into the Icon child). The Icon inherits the color via currentColor. */
84
+ .acc-chevron {
85
+ display: inline-flex;
83
86
  color: var(--text-muted);
84
87
  transition: transform 0.15s var(--ease);
85
88
  }
86
- details[open] summary :global(.icon) {
89
+ details[open] summary .acc-chevron {
87
90
  transform: rotate(180deg);
88
91
  }
89
92
  .acc-panel {
@@ -6,6 +6,7 @@
6
6
  // the result for screen readers. Dependency-free.
7
7
  import Button from '../atoms/Button.svelte';
8
8
  import Icon from '../atoms/Icon.svelte';
9
+ import { copyToClipboard } from '../../clipboard';
9
10
 
10
11
  let {
11
12
  text,
@@ -31,7 +32,7 @@
31
32
  let timer: ReturnType<typeof setTimeout> | undefined;
32
33
 
33
34
  async function copy() {
34
- const ok = await writeClipboard(text);
35
+ const ok = await copyToClipboard(text);
35
36
  copied = ok;
36
37
  status = ok ? copiedLabel : 'Copy failed';
37
38
  clearTimeout(timer);
@@ -40,31 +41,6 @@
40
41
  status = '';
41
42
  }, resetMs);
42
43
  }
43
-
44
- async function writeClipboard(value: string): Promise<boolean> {
45
- try {
46
- if (navigator.clipboard?.writeText) {
47
- await navigator.clipboard.writeText(value);
48
- return true;
49
- }
50
- } catch {
51
- /* fall through to legacy path */
52
- }
53
- // Fallback for insecure contexts / older browsers.
54
- try {
55
- const ta = document.createElement('textarea');
56
- ta.value = value;
57
- ta.style.position = 'fixed';
58
- ta.style.opacity = '0';
59
- document.body.appendChild(ta);
60
- ta.select();
61
- const ok = document.execCommand('copy');
62
- document.body.removeChild(ta);
63
- return ok;
64
- } catch {
65
- return false;
66
- }
67
- }
68
44
  </script>
69
45
 
70
46
  <Button
@@ -34,9 +34,9 @@
34
34
  </script>
35
35
 
36
36
  <label
37
- class="btn file-btn {klass}"
38
- class:btn-primary={variant === 'primary'}
39
- class:btn-ghost={variant === 'ghost'}
37
+ class="file-btn {klass}"
38
+ class:primary={variant === 'primary'}
39
+ class:ghost={variant === 'ghost'}
40
40
  class:disabled
41
41
  >
42
42
  <input
@@ -52,8 +52,50 @@
52
52
  </label>
53
53
 
54
54
  <style>
55
+ /* FileButton is a <label> (so the native file input stays inside it and
56
+ keyboard-focusable), not a <button>, so it can't be the Button atom — it
57
+ owns the canonical control look here, from the same tokens. */
55
58
  .file-btn {
59
+ display: inline-flex;
60
+ align-items: center;
61
+ justify-content: center;
62
+ gap: var(--sp-2);
63
+ padding: var(--sp-2) var(--sp-4);
64
+ min-height: 2.5rem;
65
+ border: 1px solid var(--border-strong);
66
+ border-radius: var(--r-md);
67
+ background: var(--surface);
68
+ color: var(--text);
69
+ font-weight: var(--fw-medium);
70
+ font-size: var(--fs-sm);
71
+ line-height: 1;
72
+ white-space: nowrap;
56
73
  cursor: pointer;
74
+ user-select: none;
75
+ transition:
76
+ background 0.12s var(--ease),
77
+ border-color 0.12s var(--ease),
78
+ opacity 0.12s var(--ease);
79
+ }
80
+ .file-btn:hover:not(.disabled) {
81
+ border-color: var(--accent);
82
+ }
83
+ .file-btn.primary {
84
+ background: var(--accent);
85
+ border-color: var(--accent);
86
+ color: var(--text-on-accent);
87
+ font-weight: var(--fw-semibold);
88
+ }
89
+ .file-btn.primary:hover:not(.disabled) {
90
+ filter: brightness(1.08);
91
+ }
92
+ .file-btn.ghost {
93
+ background: transparent;
94
+ border-color: transparent;
95
+ }
96
+ .file-btn.ghost:hover:not(.disabled) {
97
+ background: var(--bg-elevated-2);
98
+ border-color: transparent;
57
99
  }
58
100
  /* The hidden input keeps focusability (sr-only, not display:none), so mirror
59
101
  its focus onto the label for a visible ring. */
@@ -10,8 +10,14 @@
10
10
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
11
11
  size?: number;
12
12
  // Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
13
- // no square `.btn-icon` box; just a muted glyph that brightens on hover.
13
+ // no square box; just a muted glyph that brightens on hover. Pair with
14
+ // `hoverDanger` to tint it red on hover (delete affordances).
14
15
  inline?: boolean;
16
+ hoverDanger?: boolean;
17
+ // Two-state icon toggle (star/pin/favourite): sets `aria-pressed` and tints
18
+ // the glyph with the accent when on. Override the tint per-instance with
19
+ // `style="--btn-on: var(--warn)"`.
20
+ pressed?: boolean;
15
21
  class?: string;
16
22
  };
17
23
 
@@ -22,6 +28,8 @@
22
28
  variant = 'ghost',
23
29
  size = 18,
24
30
  inline = false,
31
+ hoverDanger = false,
32
+ pressed,
25
33
  disabled = false,
26
34
  onclick,
27
35
  class: klass = '',
@@ -30,7 +38,19 @@
30
38
  </script>
31
39
 
32
40
  <!-- Composition: the icon-only button is a Button (canonical control styling)
33
- carrying the square `.btn-icon` modifier, wrapping an Icon. -->
34
- <Button {...rest} {variant} {disabled} {title} {onclick} class="{inline ? 'btn-icon-inline' : 'btn-icon'} {klass}" aria-label={label}>
41
+ in its icon variant, wrapping an Icon. -->
42
+ <Button
43
+ {...rest}
44
+ {variant}
45
+ {disabled}
46
+ {title}
47
+ {onclick}
48
+ icon={!inline}
49
+ iconInline={inline}
50
+ {hoverDanger}
51
+ aria-pressed={pressed}
52
+ class={klass}
53
+ aria-label={label}
54
+ >
35
55
  <Icon name={icon} {size} />
36
56
  </Button>
@@ -6,6 +6,8 @@ type IconButtonProps = HTMLButtonAttributes & {
6
6
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
7
7
  size?: number;
8
8
  inline?: boolean;
9
+ hoverDanger?: boolean;
10
+ pressed?: boolean;
9
11
  class?: string;
10
12
  };
11
13
  declare const IconButton: import("svelte").Component<IconButtonProps, {}, "">;
@@ -14,12 +14,16 @@
14
14
  onclose,
15
15
  body,
16
16
  footer,
17
+ size = 'md',
17
18
  resizeKey
18
19
  }: {
19
20
  title: string;
20
21
  onclose: () => void;
21
22
  body: Snippet;
22
23
  footer?: Snippet;
24
+ /** Desktop width preset (sm 24rem / md 34rem / lg 48rem). A `resizeKey`
25
+ * drag still overrides it. */
26
+ size?: 'sm' | 'md' | 'lg';
23
27
  /** When set, the sheet is horizontally resizable on desktop and the chosen
24
28
  * width persists under this localStorage key. */
25
29
  resizeKey?: string;
@@ -104,7 +108,7 @@
104
108
  }}
105
109
  onclick={onDialogClick}
106
110
  >
107
- <div class="sheet">
111
+ <div class="sheet" class:sheet-sm={size === 'sm'} class:sheet-lg={size === 'lg'}>
108
112
  <div class="sheet-head">
109
113
  <span id={titleId} class="sheet-title truncate">{title}</span>
110
114
  <div class="spacer"></div>
@@ -167,9 +171,10 @@
167
171
  }
168
172
 
169
173
  .sheet {
174
+ --sw: 34rem; /* width preset; overridden by --sheet-w when resized */
170
175
  position: relative;
171
176
  width: 100%;
172
- max-width: 34rem;
177
+ max-width: var(--sw);
173
178
  max-height: calc(100dvh - var(--safe-top) - var(--sp-6));
174
179
  display: flex;
175
180
  flex-direction: column;
@@ -184,8 +189,14 @@
184
189
  .sheet {
185
190
  border-radius: var(--r-lg);
186
191
  padding-bottom: 0;
187
- width: var(--sheet-w, 34rem);
188
- max-width: min(var(--sheet-w, 34rem), calc(100vw - 2rem));
192
+ width: var(--sheet-w, var(--sw));
193
+ max-width: min(var(--sheet-w, var(--sw)), calc(100vw - 2rem));
194
+ }
195
+ .sheet-sm {
196
+ --sw: 24rem;
197
+ }
198
+ .sheet-lg {
199
+ --sw: 48rem;
189
200
  }
190
201
  }
191
202
  @keyframes sheet-up {
@@ -4,6 +4,9 @@ type $$ComponentProps = {
4
4
  onclose: () => void;
5
5
  body: Snippet;
6
6
  footer?: Snippet;
7
+ /** Desktop width preset (sm 24rem / md 34rem / lg 48rem). A `resizeKey`
8
+ * drag still overrides it. */
9
+ size?: 'sm' | 'md' | 'lg';
7
10
  /** When set, the sheet is horizontally resizable on desktop and the chosen
8
11
  * width persists under this localStorage key. */
9
12
  resizeKey?: string;
@@ -5,17 +5,16 @@
5
5
  // adapter, green/blue/red for permission mode). `row` lays the content out
6
6
  // horizontally (icon + label) instead of the default label-over-hint column.
7
7
  //
8
- // Specializes the Button atom (ghost variant) so it inherits the canonical
9
- // disabled/focus/transition behaviour; the card surface + selection ring are
10
- // overrides. Button's variant classes are scoped via :where() (0 specificity),
11
- // so `.btn.opt-btn` (0,2,0) wins cleanly over `.btn-ghost`.
8
+ // A selection card shares almost nothing visually with Button, so it owns its
9
+ // own <button> + scoped styles rather than specializing the Button atom.
12
10
  import type { Snippet } from 'svelte';
13
11
  import type { HTMLButtonAttributes } from 'svelte/elements';
14
- import Button from '../atoms/Button.svelte';
15
12
 
16
13
  let {
17
14
  selected = false,
18
15
  row = false,
16
+ type = 'button',
17
+ disabled = false,
19
18
  class: klass = '',
20
19
  children,
21
20
  ...rest
@@ -26,40 +25,44 @@
26
25
  } = $props();
27
26
  </script>
28
27
 
29
- <Button
28
+ <button
30
29
  {...rest}
31
- variant="ghost"
32
- class="opt-btn {row ? 'row' : ''} {selected ? 'sel' : ''} {klass}"
30
+ {type}
31
+ {disabled}
32
+ class="opt {klass}"
33
+ class:row
34
+ class:selected
33
35
  aria-pressed={selected}
34
36
  >
35
37
  {@render children?.()}
36
- </Button>
38
+ </button>
37
39
 
38
40
  <style>
39
- /* Rendered inside Button, so target via :global; `.btn.opt-btn` outranks the
40
- atom's :where()-scoped variant classes. */
41
- :global(.btn.opt-btn) {
41
+ .opt {
42
42
  display: flex;
43
43
  flex-direction: column;
44
44
  gap: 2px;
45
45
  padding: var(--sp-2);
46
- min-height: 0;
47
46
  background: var(--bg);
48
47
  border: 1px solid var(--border-strong);
49
48
  border-radius: var(--r-md);
50
49
  color: var(--text);
51
50
  text-align: left;
52
51
  white-space: normal;
52
+ user-select: none;
53
+ transition:
54
+ background 0.12s var(--ease),
55
+ border-color 0.12s var(--ease),
56
+ color 0.12s var(--ease);
53
57
  }
54
- /* Hover only restyles UNselected cards. Excluding `.sel` matters because this
55
- rule is (0,4,0) — the `:not(:disabled)` pseudo-class pushes it above
56
- `.btn.opt-btn.sel` (0,3,0) — so without the guard it would strip a selected
57
- card's accent border + tint on hover, leaving a half-styled card. */
58
- :global(.btn.opt-btn:hover:not(:disabled):not(.sel)) {
58
+ .opt:disabled {
59
+ opacity: 0.45;
60
+ cursor: not-allowed;
61
+ }
62
+ .opt:hover:not(:disabled):not(.selected) {
59
63
  border-color: var(--border-strong);
60
- background: var(--bg);
61
64
  }
62
- :global(.btn.opt-btn.row) {
65
+ .opt.row {
63
66
  flex-direction: row;
64
67
  align-items: center;
65
68
  justify-content: center;
@@ -67,22 +70,17 @@
67
70
  color: var(--text-muted);
68
71
  font-weight: var(--fw-medium);
69
72
  }
70
- :global(.btn.opt-btn.sel) {
73
+ .opt.selected {
71
74
  --oc: var(--opt-accent, var(--accent));
75
+ /* Retint slotted `.faint` hint text toward the accent — via an inherited
76
+ custom property, so no :global reach into slotted content. */
77
+ --faint-color: color-mix(in srgb, var(--oc) 70%, var(--text-muted));
72
78
  border-color: var(--oc);
73
79
  background: color-mix(in srgb, var(--oc) 14%, var(--bg));
74
80
  color: var(--oc);
75
81
  }
76
- /* Reassert the accent on a hovered selected card. Button's `.btn-ghost:hover`
77
- (0,3,0) ties with `.sel` and wins on source order, repainting the card with
78
- the neutral elevated hover bg + transparent border; this rule (0,5,0) beats
79
- it so the selection stays visible, slightly brightened for hover affordance. */
80
- :global(.btn.opt-btn.sel:hover:not(:disabled)) {
82
+ .opt.selected:hover:not(:disabled) {
81
83
  border-color: var(--oc);
82
84
  background: color-mix(in srgb, var(--oc) 20%, var(--bg));
83
85
  }
84
- /* The slotted hint text (a global `.faint`) tints toward the accent too. */
85
- :global(.btn.opt-btn.sel .faint) {
86
- color: color-mix(in srgb, var(--oc) 70%, var(--text-muted));
87
- }
88
86
  </style>
@@ -9,6 +9,7 @@
9
9
  // ships beyond Chromium; the popover semantics above are the hard part and
10
10
  // are broadly supported today.)
11
11
  import type { Snippet } from 'svelte';
12
+ import { place } from '../../floating';
12
13
 
13
14
  type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
14
15
 
@@ -18,6 +19,8 @@
18
19
  label,
19
20
  trigger,
20
21
  children,
22
+ triggerClass = '',
23
+ bare = false,
21
24
  onopen,
22
25
  onclose
23
26
  }: {
@@ -25,11 +28,16 @@
25
28
  gap?: number;
26
29
  /** Accessible name for the trigger. */
27
30
  label: string;
28
- /** Trigger content (rendered inside a ghost button that owns the popover
29
- * wiring). */
31
+ /** Trigger content (rendered inside a button that owns the popover wiring). */
30
32
  trigger: Snippet;
31
33
  /** Panel content. */
32
34
  children: Snippet;
35
+ /** Extra class on the trigger button — style it from your own scoped CSS,
36
+ * no :global needed (you supply the class). */
37
+ triggerClass?: string;
38
+ /** Drop the default ghost-icon chrome so the trigger is an unstyled button
39
+ * you fully own (pair with `triggerClass`). */
40
+ bare?: boolean;
33
41
  onopen?: () => void;
34
42
  onclose?: () => void;
35
43
  } = $props();
@@ -38,34 +46,19 @@
38
46
  let triggerEl = $state<HTMLButtonElement | null>(null);
39
47
  let panelEl = $state<HTMLDivElement | null>(null);
40
48
 
41
- function place() {
42
- if (!triggerEl || !panelEl) return;
43
- const t = triggerEl.getBoundingClientRect();
44
- const p = panelEl.getBoundingClientRect();
45
- const vw = document.documentElement.clientWidth;
46
- const vh = document.documentElement.clientHeight;
47
-
48
- let top = placement.startsWith('bottom') ? t.bottom + gap : t.top - p.height - gap;
49
- let left = placement.endsWith('end') ? t.right - p.width : t.left;
50
-
51
- // Flip vertically / clamp horizontally to stay on screen.
52
- if (top + p.height > vh && t.top - p.height - gap > 0) top = t.top - p.height - gap;
53
- if (top < 0) top = gap;
54
- left = Math.max(gap, Math.min(left, vw - p.width - gap));
55
-
56
- panelEl.style.top = `${Math.round(top)}px`;
57
- panelEl.style.left = `${Math.round(left)}px`;
49
+ function reposition() {
50
+ if (triggerEl && panelEl) place(triggerEl, panelEl, placement, gap);
58
51
  }
59
52
 
60
53
  function onToggle(e: ToggleEvent) {
61
54
  if (e.newState === 'open') {
62
- place();
63
- addEventListener('scroll', place, true);
64
- addEventListener('resize', place);
55
+ reposition();
56
+ addEventListener('scroll', reposition, true);
57
+ addEventListener('resize', reposition);
65
58
  onopen?.();
66
59
  } else {
67
- removeEventListener('scroll', place, true);
68
- removeEventListener('resize', place);
60
+ removeEventListener('scroll', reposition, true);
61
+ removeEventListener('resize', reposition);
69
62
  onclose?.();
70
63
  }
71
64
  }
@@ -74,7 +67,8 @@
74
67
  <button
75
68
  bind:this={triggerEl}
76
69
  type="button"
77
- class="btn btn-ghost btn-icon pop-trigger"
70
+ class="pop-trigger {triggerClass}"
71
+ class:bare
78
72
  popovertarget={id}
79
73
  aria-label={label}
80
74
  >
@@ -94,10 +88,37 @@
94
88
  </div>
95
89
 
96
90
  <style>
91
+ /* The trigger owns its look (a ghost icon-button) from tokens — it no longer
92
+ borrows global .btn classes, so a consumer can restyle it via `triggerClass`
93
+ (+ `bare`) from their own scoped CSS instead of fighting globals. */
97
94
  .pop-trigger {
98
95
  display: inline-flex;
99
96
  align-items: center;
100
97
  justify-content: center;
98
+ min-height: 2.25rem;
99
+ min-width: 2.25rem;
100
+ padding: var(--sp-2);
101
+ border: 1px solid transparent;
102
+ border-radius: var(--r-md);
103
+ background: transparent;
104
+ color: var(--text);
105
+ transition:
106
+ background 0.12s var(--ease),
107
+ border-color 0.12s var(--ease);
108
+ }
109
+ .pop-trigger:hover {
110
+ background: var(--bg-elevated-2);
111
+ }
112
+ /* `bare`: strip the chrome down to a plain button the consumer styles. */
113
+ .pop-trigger.bare {
114
+ min-height: 0;
115
+ min-width: 0;
116
+ padding: 0;
117
+ border: 0;
118
+ background: none;
119
+ }
120
+ .pop-trigger.bare:hover {
121
+ background: none;
101
122
  }
102
123
  .pop-panel {
103
124
  position: fixed;