@dorsk/tsumikit 0.37.5 → 0.38.0

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.
package/README.md CHANGED
@@ -149,7 +149,7 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
149
149
  `onenter(value)`; the bare `<input>` DOM is unchanged unless `icon`/`clearable`
150
150
  is used), Textarea, Select (`options` array with per-option `icon`/`emoji`/`hint`/`disabled`;
151
151
  the trigger overlays the selected option's glyph + muted hint on the native control), Switch, Checkbox,
152
- Slider, Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
152
+ Slider (`ticks` draws a dot per step for a segmented slider), Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
153
153
  threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
154
154
  fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
155
155
  (`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
@@ -164,7 +164,7 @@ Tabs, RadioGroup (`variant="rows"`: bordered rows, per-option `note`/`descriptio
164
164
  `action(option)` trailing control that never toggles, `below(option)` inline panel),
165
165
  Tooltip, Accordion, CopyButton, FileButton,
166
166
  Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
167
- ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore,
167
+ ThemePicker (popover grid of 2×2 palette swatches: bg · surface · text · accent per theme), FontScalePicker (popover with a stepped slider across the five text sizes), SectionHeader, KeyValue, LoadMore,
168
168
  GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
169
169
  auto/never/glyph degrades to icons inside a narrow `.cq` container),
170
170
  CapBar (consumption track with a draggable, keyboard-steppable cap handle; `oninput` live, `onchange` on commit),
@@ -20,6 +20,7 @@
20
20
  'aria-describedby': ariaDescribedby,
21
21
  'aria-invalid': ariaInvalid,
22
22
  invalid = false,
23
+ ticks = false,
23
24
  class: klass = '',
24
25
  el = $bindable(null),
25
26
  ...rest
@@ -33,6 +34,9 @@
33
34
  format?: (v: number) => string;
34
35
  /** Error state: aria-invalid. */
35
36
  invalid?: boolean;
37
+ /** Visible dots at each step (segmented slider). `true` = one per `step`;
38
+ * a number array places dots at those values. */
39
+ ticks?: boolean | number[];
36
40
  id?: string;
37
41
  'aria-describedby'?: string | null;
38
42
  'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
@@ -46,13 +50,19 @@
46
50
 
47
51
  $effect(() => warnUnlabelled(el, 'Slider'));
48
52
 
49
- // Fill percentage for the track gradient.
50
- const pct = $derived(
51
- Math.max(0, Math.min(100, ((Number(value) - +min) / (+max - +min || 1)) * 100))
52
- );
53
+ const frac = (v: number) => Math.max(0, Math.min(1, (v - +min) / (+max - +min || 1)));
54
+ const pct = $derived(frac(Number(value)) * 100);
55
+ const tickValues = $derived.by(() => {
56
+ if (!ticks) return [];
57
+ if (Array.isArray(ticks)) return ticks;
58
+ const n = Math.floor((+max - +min) / (+step || 1));
59
+ if (n > 200) return [];
60
+ return Array.from({ length: n + 1 }, (_, i) => +min + i * +step);
61
+ });
53
62
  </script>
54
63
 
55
- <div class="slider {klass}" style="--pct: {pct}%" data-tsu="Slider">
64
+ <div class="slider {klass}" class:has-ticks={tickValues.length > 0} style="--pct: {pct}%" data-tsu="Slider">
65
+ <span class="range">
56
66
  <input
57
67
  bind:this={el}
58
68
  {id}
@@ -66,6 +76,14 @@
66
76
  aria-describedby={ariaDescribedby ?? field?.describedBy}
67
77
  aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
68
78
  />
79
+ {#if tickValues.length > 0}
80
+ <span class="ticks" aria-hidden="true">
81
+ {#each tickValues as t (t)}
82
+ <i class="tick" class:reached={t <= Number(value)} style="--f: {frac(t)}"></i>
83
+ {/each}
84
+ </span>
85
+ {/if}
86
+ </span>
69
87
  {#if showValue}
70
88
  <output for={id} class="slider-out">{format(Number(value))}</output>
71
89
  {/if}
@@ -78,10 +96,18 @@
78
96
  gap: var(--sp-3);
79
97
  width: 100%;
80
98
  }
99
+ .range {
100
+ position: relative;
101
+ display: flex;
102
+ flex: 1;
103
+ min-width: 0;
104
+ }
81
105
  input[type='range'] {
82
106
  appearance: none;
83
107
  -webkit-appearance: none;
84
108
  flex: 1;
109
+ position: relative;
110
+ z-index: 1;
85
111
  height: 1.25rem;
86
112
  background: none;
87
113
  cursor: pointer;
@@ -145,6 +171,26 @@
145
171
  opacity: 0.45;
146
172
  cursor: not-allowed;
147
173
  }
174
+ .ticks {
175
+ position: absolute;
176
+ inset: 0;
177
+ z-index: 2;
178
+ pointer-events: none;
179
+ }
180
+ .tick {
181
+ position: absolute;
182
+ top: 50%;
183
+ left: calc(0.6rem + (100% - 1.2rem) * var(--f));
184
+ width: 0.35rem;
185
+ height: 0.35rem;
186
+ margin: -0.175rem 0 0 -0.175rem;
187
+ border-radius: 50%;
188
+ background: var(--border-strong);
189
+ }
190
+ .tick.reached {
191
+ background: var(--bg);
192
+ opacity: 0.6;
193
+ }
148
194
  .slider-out {
149
195
  min-width: 2.5rem;
150
196
  text-align: right;
@@ -9,6 +9,9 @@ type $$ComponentProps = HTMLInputAttributes & {
9
9
  format?: (v: number) => string;
10
10
  /** Error state: aria-invalid. */
11
11
  invalid?: boolean;
12
+ /** Visible dots at each step (segmented slider). `true` = one per `step`;
13
+ * a number array places dots at those values. */
14
+ ticks?: boolean | number[];
12
15
  id?: string;
13
16
  'aria-describedby'?: string | null;
14
17
  'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
@@ -1,22 +1,86 @@
1
1
  <script lang="ts">
2
- // UI text-size switcher. Same compact native-select-over-button pattern as
3
- // ThemePicker, wired to the font-scale store. Drives --fs-scale (text only) —
4
- // chrome dimensions stay fixed. Glyph is the conventional "A".
5
- import SelectButton from './SelectButton.svelte';
2
+ // Text-size control: the "A" trigger opens a popover with a stepped slider
3
+ // across SCALE_LEVELS. Drives --fs-scale (text only), chrome stays fixed.
4
+ import Popover from './Popover.svelte';
5
+ import Slider from '../atoms/Slider.svelte';
6
6
  import { fontScale, SCALE_LEVELS } from '../../stores/fontscale.svelte';
7
7
 
8
8
  let { class: klass = '' }: { class?: string } = $props();
9
9
 
10
- const options = SCALE_LEVELS.map((l) => ({ value: l.id, label: l.label }));
10
+ const index = $derived(Math.max(0, SCALE_LEVELS.findIndex((l) => l.id === fontScale.levelId)));
11
+ const level = $derived(SCALE_LEVELS[index]);
12
+ const set = (i: number) => fontScale.set(SCALE_LEVELS[Math.max(0, Math.min(SCALE_LEVELS.length - 1, i))].id);
11
13
  </script>
12
14
 
13
- <SelectButton
14
- data-tsu="FontScalePicker"
15
- class={klass}
16
- glyph="A"
17
- label="Text size"
18
- title="Text size"
19
- value={fontScale.levelId}
20
- {options}
21
- onchange={(v) => fontScale.set(v)}
22
- />
15
+ <Popover label="Text size" placement="bottom-end" triggerClass={klass} box="md">
16
+ {#snippet trigger()}<span class="glyph" data-tsu="FontScalePicker" title="Text size: {level.label}">A</span>{/snippet}
17
+ <div class="panel">
18
+ <div class="row">
19
+ <button type="button" class="end small" aria-label="Smaller text" onclick={() => set(index - 1)}>a</button>
20
+ <Slider
21
+ value={index}
22
+ min={0}
23
+ max={SCALE_LEVELS.length - 1}
24
+ step={1}
25
+ ticks
26
+ label="Text size"
27
+ aria-valuetext={level.label}
28
+ oninput={(e) => set(Number((e.currentTarget as HTMLInputElement).value))}
29
+ />
30
+ <button type="button" class="end large" aria-label="Larger text" onclick={() => set(index + 1)}>A</button>
31
+ </div>
32
+ <div class="caption" aria-live="polite">{level.label} · {Math.round(level.value * 100)}%</div>
33
+ </div>
34
+ </Popover>
35
+
36
+ <style>
37
+ .glyph {
38
+ font-weight: var(--fw-bold);
39
+ font-size: var(--fs-md);
40
+ line-height: 1;
41
+ }
42
+ .panel {
43
+ width: 15rem;
44
+ padding: var(--sp-2) var(--sp-3);
45
+ }
46
+ .row {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: var(--sp-2);
50
+ }
51
+ .end {
52
+ flex: none;
53
+ width: 1.75rem;
54
+ height: 1.75rem;
55
+ padding: 0;
56
+ border: 0;
57
+ border-radius: var(--r-sm);
58
+ background: none;
59
+ color: var(--text-muted);
60
+ font-family: inherit;
61
+ font-weight: var(--fw-bold);
62
+ line-height: 1;
63
+ cursor: pointer;
64
+ }
65
+ .end:hover {
66
+ background: var(--bg-elevated-2);
67
+ color: var(--text);
68
+ }
69
+ .end:focus-visible {
70
+ outline: 2px solid var(--accent);
71
+ outline-offset: 1px;
72
+ }
73
+ .small {
74
+ font-size: var(--fs-xs);
75
+ }
76
+ .large {
77
+ font-size: var(--fs-lg);
78
+ }
79
+ .caption {
80
+ margin-top: var(--sp-1);
81
+ text-align: center;
82
+ font-size: var(--fs-xs);
83
+ color: var(--text-muted);
84
+ font-variant-numeric: tabular-nums;
85
+ }
86
+ </style>
@@ -1,30 +1,136 @@
1
1
  <script lang="ts">
2
- // Theme switcher: a compact icon-button hosting a native <select> over the
3
- // full theme registry (built-ins + theme.register()), wired to the global
4
- // theme store. The native control gives correct mobile/keyboard behaviour and
5
- // outside-click handling for free. The button glyph reflects the active theme.
6
- import SelectButton from './SelectButton.svelte';
2
+ // Theme switcher: the trigger shows the active theme's palette swatch and
3
+ // opens a popover grid of swatches, light then dark. Each swatch is scoped
4
+ // with data-theme so its four quadrants resolve that theme's raw palette
5
+ // (--c-bg, --c-surface, --c-text, --c-accent; the derived --bg etc. are
6
+ // resolved once at :root and would not re-scope). The root default theme
7
+ // has no [data-theme] block, so its swatch carries the :root values.
8
+ import Popover from './Popover.svelte';
7
9
  import { type ThemeDef, theme } from '../../stores/theme.svelte';
8
10
 
9
11
  let { class: klass = '' }: { class?: string } = $props();
10
12
 
11
- const toOption = (t: ThemeDef) => ({
12
- value: t.id,
13
- label: `${t.icon ?? theme.fallbackIcon} ${t.label}`
14
- });
15
- const groups = $derived([
16
- { label: '— light', options: theme.all.filter((t) => t.mode === 'light').map(toOption) },
17
- { label: '— dark', options: theme.all.filter((t) => t.mode === 'dark').map(toOption) }
18
- ]);
13
+ let hovered = $state<ThemeDef | null>(null);
14
+ const shown = $derived(hovered ?? theme.option);
15
+ const groups = $derived(
16
+ (['light', 'dark'] as const).map((mode) => ({
17
+ mode,
18
+ themes: theme.all.filter((t) => t.mode === mode)
19
+ }))
20
+ );
19
21
  </script>
20
22
 
21
- <SelectButton
22
- data-tsu="ThemePicker"
23
- class={klass}
24
- glyph={theme.icon}
25
- label="Theme"
26
- title={`Theme: ${theme.label}`}
27
- value={theme.current}
28
- {groups}
29
- onchange={(v) => theme.set(v)}
30
- />
23
+ {#snippet swatch(id: string)}
24
+ <span class="swatch" data-theme={id} aria-hidden="true">
25
+ <i class="q bg"></i><i class="q surface"></i><i class="q text"></i><i class="q accent"></i>
26
+ </span>
27
+ {/snippet}
28
+
29
+ <Popover label="Theme: {theme.label}" placement="bottom-end" triggerClass={klass} box="md">
30
+ {#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" title="Theme: {theme.label}">{@render swatch(theme.current)}</span>{/snippet}
31
+ <div class="panel">
32
+ {#each groups as g (g.mode)}
33
+ <div class="group-label">{g.mode}</div>
34
+ <div class="grid" role="group" aria-label="{g.mode} themes">
35
+ {#each g.themes as t (t.id)}
36
+ <button
37
+ type="button"
38
+ class="cell"
39
+ class:current={t.id === theme.current}
40
+ aria-pressed={t.id === theme.current}
41
+ aria-label={t.label}
42
+ title="{t.icon ?? theme.fallbackIcon} {t.label}"
43
+ onclick={() => theme.set(t.id)}
44
+ onpointerenter={() => (hovered = t)}
45
+ onpointerleave={() => (hovered = null)}
46
+ onfocus={() => (hovered = t)}
47
+ onblur={() => (hovered = null)}
48
+ >
49
+ {@render swatch(t.id)}
50
+ </button>
51
+ {/each}
52
+ </div>
53
+ {/each}
54
+ <div class="caption" aria-live="polite">{shown.icon ?? theme.fallbackIcon} {shown.label}</div>
55
+ </div>
56
+ </Popover>
57
+
58
+ <style>
59
+ .trigger {
60
+ display: inline-flex;
61
+ }
62
+ .swatch {
63
+ display: grid;
64
+ grid-template-columns: 1fr 1fr;
65
+ width: 1.25rem;
66
+ height: 1.25rem;
67
+ overflow: hidden;
68
+ border-radius: var(--r-sm);
69
+ box-shadow: inset 0 0 0 1px var(--border);
70
+ }
71
+ .swatch[data-theme='dark'] {
72
+ --c-bg: #0f1115;
73
+ --c-surface: #21262f;
74
+ --c-text: #e6e9ef;
75
+ --c-accent: #5ad6a0;
76
+ }
77
+ .q {
78
+ display: block;
79
+ }
80
+ .q.bg {
81
+ background: var(--c-bg);
82
+ }
83
+ .q.surface {
84
+ background: var(--c-surface);
85
+ }
86
+ .q.text {
87
+ background: var(--c-text);
88
+ }
89
+ .q.accent {
90
+ background: var(--c-accent);
91
+ }
92
+ .panel {
93
+ padding: var(--sp-2);
94
+ width: max-content;
95
+ }
96
+ .group-label {
97
+ margin: var(--sp-1) var(--sp-1) var(--sp-1);
98
+ font-size: var(--fs-xs);
99
+ text-transform: uppercase;
100
+ letter-spacing: 0.06em;
101
+ color: var(--text-faint);
102
+ }
103
+ .grid {
104
+ display: grid;
105
+ grid-template-columns: repeat(6, auto);
106
+ gap: var(--sp-1);
107
+ }
108
+ .cell {
109
+ display: inline-flex;
110
+ padding: 3px;
111
+ border: 2px solid transparent;
112
+ border-radius: var(--r-md);
113
+ background: none;
114
+ cursor: pointer;
115
+ }
116
+ .cell .swatch {
117
+ width: 1.6rem;
118
+ height: 1.6rem;
119
+ }
120
+ .cell:hover {
121
+ background: var(--bg-elevated-2);
122
+ }
123
+ .cell.current {
124
+ border-color: var(--accent);
125
+ }
126
+ .cell:focus-visible {
127
+ outline: 2px solid var(--accent);
128
+ outline-offset: 1px;
129
+ }
130
+ .caption {
131
+ margin-top: var(--sp-2);
132
+ text-align: center;
133
+ font-size: var(--fs-sm);
134
+ color: var(--text-muted);
135
+ }
136
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.37.5",
3
+ "version": "0.38.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",