terminoir 0.16.0 → 0.17.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
@@ -140,6 +140,7 @@ Import from **`terminoir`**:
140
140
  | `FontPicker` | UI + code font lists, lazy `@fontsource`, persisted per `appId` |
141
141
  | `NavIcon` | `name`, optional `color` — Font Awesome 6 (`fa-*` or plain ids like `inbox`); host app loads FA CSS |
142
142
  | `IconField` | `bind:value` icon id picker (`ICON_PICKER_OPTIONS`); `compact` opens the grid in a popover |
143
+ | `IconColorField` | One square button showing an icon in its colour; opens colour swatches and an icon grid (e.g. beside a name field or page title) |
143
144
 
144
145
  ### App kit (0.5)
145
146
 
@@ -0,0 +1,200 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+ import { ICON_PICKER_OPTIONS } from '../utils/icons.js';
5
+ import NavIcon from './NavIcon.svelte';
6
+
7
+ type ColorChoice = { id: string; label: string; /** CSS colour shown for it. */ css: string };
8
+
9
+ let {
10
+ label = 'Icon',
11
+ icon = $bindable(''),
12
+ color = $bindable(''),
13
+ colors,
14
+ options = ICON_PICKER_OPTIONS,
15
+ noneColor = 'var(--muted-foreground)',
16
+ quiet = false,
17
+ onchange,
18
+ class: className = '',
19
+ }: {
20
+ /** Accessible name of the button and its popover. */
21
+ label?: string;
22
+ /** NavIcon name; a circle shows while empty. */
23
+ icon?: string;
24
+ /** A `colors` id; empty for no colour. */
25
+ color?: string;
26
+ colors: ColorChoice[];
27
+ options?: ReadonlyArray<{ id: string; label: string }>;
28
+ /** How the icon looks with no colour picked. */
29
+ noneColor?: string;
30
+ /** Borderless until hovered, e.g. beside a page title. */
31
+ quiet?: boolean;
32
+ /** Called after a pick, with both values. */
33
+ onchange?: (value: { icon: string; color: string }) => void;
34
+ class?: string;
35
+ } = $props();
36
+
37
+ let open = $state(false);
38
+ let root: HTMLDivElement | undefined = $state();
39
+ let trigger: HTMLButtonElement | undefined = $state();
40
+ let popover: HTMLDivElement | undefined = $state();
41
+
42
+ const shownColor = $derived(colors.find((c) => c.id === color));
43
+ const css = $derived(shownColor?.css ?? noneColor);
44
+ const iconLabel = $derived(options.find((o) => o.id === icon)?.label ?? icon ?? 'None');
45
+
46
+ async function show() {
47
+ open = true;
48
+ await tick();
49
+ popover?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus();
50
+ }
51
+
52
+ function hide() {
53
+ open = false;
54
+ trigger?.focus();
55
+ }
56
+
57
+ function pickColor(id: string) {
58
+ color = id;
59
+ onchange?.({ icon, color });
60
+ }
61
+
62
+ function pickIcon(id: string) {
63
+ icon = id;
64
+ onchange?.({ icon, color });
65
+ hide();
66
+ }
67
+
68
+ $effect(() => {
69
+ if (!open) return;
70
+ const onPointer = (event: PointerEvent) => {
71
+ if (root && !root.contains(event.target as Node)) open = false;
72
+ };
73
+ const onKey = (event: KeyboardEvent) => {
74
+ if (event.key !== 'Escape') return;
75
+ event.stopPropagation();
76
+ hide();
77
+ };
78
+ document.addEventListener('pointerdown', onPointer);
79
+ document.addEventListener('keydown', onKey, true);
80
+ return () => {
81
+ document.removeEventListener('pointerdown', onPointer);
82
+ document.removeEventListener('keydown', onKey, true);
83
+ };
84
+ });
85
+ </script>
86
+
87
+ <div bind:this={root} class={cn('terminoir-icon-color', className)}>
88
+ <button
89
+ bind:this={trigger}
90
+ type="button"
91
+ class={cn('terminoir-icon-color__trigger terminoir-focusable', quiet && 'terminoir-icon-color__trigger--quiet')}
92
+ aria-label={`${label}: ${iconLabel}, ${shownColor?.label ?? 'no colour'}`}
93
+ title={label}
94
+ aria-haspopup="dialog"
95
+ aria-expanded={open}
96
+ onclick={() => (open ? hide() : void show())}
97
+ >
98
+ <NavIcon name={icon || 'circle'} color={css} />
99
+ </button>
100
+ {#if open}
101
+ <div bind:this={popover} class="terminoir-icon-color__popover glass-popover" role="dialog" aria-label={label}>
102
+ <div class="terminoir-icon-color__swatches" role="radiogroup" aria-label="Colour">
103
+ {#each [{ id: '', label: 'No colour', css: noneColor }, ...colors] as choice (choice.id)}
104
+ <button
105
+ type="button"
106
+ class="terminoir-icon-color__swatch terminoir-focusable"
107
+ class:terminoir-icon-color__swatch--none={!choice.id}
108
+ role="radio"
109
+ aria-checked={color === choice.id}
110
+ aria-label={choice.label}
111
+ title={choice.label}
112
+ style:--swatch={choice.css}
113
+ onclick={() => pickColor(choice.id)}
114
+ ></button>
115
+ {/each}
116
+ </div>
117
+ <div class="terminoir-icon-field__grid" role="radiogroup" aria-label="Icon">
118
+ {#each options as option (option.id)}
119
+ <button
120
+ type="button"
121
+ class="terminoir-icon-field__option terminoir-focusable"
122
+ class:terminoir-icon-field__option--selected={icon === option.id}
123
+ role="radio"
124
+ aria-checked={icon === option.id}
125
+ aria-label={option.label}
126
+ title={option.label}
127
+ onclick={() => pickIcon(option.id)}
128
+ >
129
+ <NavIcon name={option.id} color={css} />
130
+ </button>
131
+ {/each}
132
+ </div>
133
+ </div>
134
+ {/if}
135
+ </div>
136
+
137
+ <style>
138
+ /* A fixed square: it never grows, even in an Inline `fill` row (the `div` outranks that rule). */
139
+ div.terminoir-icon-color {
140
+ position: relative;
141
+ display: inline-flex;
142
+ flex: none;
143
+ }
144
+ .terminoir-icon-color__trigger {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ width: 2.5rem;
149
+ height: 2.5rem;
150
+ padding: 0;
151
+ border: 1px solid var(--glass-border, var(--border));
152
+ border-radius: var(--radius-control);
153
+ background: color-mix(in srgb, var(--foreground) 4%, transparent);
154
+ font-size: 1.05rem;
155
+ cursor: pointer;
156
+ }
157
+ .terminoir-icon-color__trigger:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); }
158
+ .terminoir-icon-color__trigger--quiet {
159
+ width: 2rem;
160
+ height: 2rem;
161
+ border-color: transparent;
162
+ background: transparent;
163
+ font-size: inherit;
164
+ }
165
+ .terminoir-icon-color__popover {
166
+ position: absolute;
167
+ top: calc(100% + var(--space-1));
168
+ left: 0;
169
+ z-index: 40;
170
+ width: max-content;
171
+ max-width: min(18rem, calc(100vw - 2rem));
172
+ padding: var(--space-2);
173
+ }
174
+ .terminoir-icon-color__popover .terminoir-icon-field__grid {
175
+ grid-template-columns: repeat(6, 2.5rem);
176
+ }
177
+ .terminoir-icon-color__swatches {
178
+ display: flex;
179
+ flex-wrap: wrap;
180
+ gap: var(--space-2);
181
+ padding: var(--space-1);
182
+ }
183
+ .terminoir-icon-color__swatch {
184
+ width: 1.35rem;
185
+ height: 1.35rem;
186
+ padding: 0;
187
+ border: 0;
188
+ border-radius: 999px;
189
+ background: var(--swatch);
190
+ cursor: pointer;
191
+ }
192
+ .terminoir-icon-color__swatch--none {
193
+ background: transparent;
194
+ box-shadow: inset 0 0 0 2px var(--swatch);
195
+ }
196
+ .terminoir-icon-color__swatch[aria-checked='true'] {
197
+ outline: 2px solid var(--foreground);
198
+ outline-offset: 2px;
199
+ }
200
+ </style>
@@ -0,0 +1,31 @@
1
+ type ColorChoice = {
2
+ id: string;
3
+ label: string; /** CSS colour shown for it. */
4
+ css: string;
5
+ };
6
+ type $$ComponentProps = {
7
+ /** Accessible name of the button and its popover. */
8
+ label?: string;
9
+ /** NavIcon name; a circle shows while empty. */
10
+ icon?: string;
11
+ /** A `colors` id; empty for no colour. */
12
+ color?: string;
13
+ colors: ColorChoice[];
14
+ options?: ReadonlyArray<{
15
+ id: string;
16
+ label: string;
17
+ }>;
18
+ /** How the icon looks with no colour picked. */
19
+ noneColor?: string;
20
+ /** Borderless until hovered, e.g. beside a page title. */
21
+ quiet?: boolean;
22
+ /** Called after a pick, with both values. */
23
+ onchange?: (value: {
24
+ icon: string;
25
+ color: string;
26
+ }) => void;
27
+ class?: string;
28
+ };
29
+ declare const IconColorField: import("svelte").Component<$$ComponentProps, {}, "icon" | "color">;
30
+ type IconColorField = ReturnType<typeof IconColorField>;
31
+ export default IconColorField;
package/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ export { default as EmptyState } from './components/EmptyState.svelte';
26
26
  export { default as FontPicker } from './components/FontPicker.svelte';
27
27
  export { default as NavIcon } from './components/NavIcon.svelte';
28
28
  export { default as IconField } from './components/IconField.svelte';
29
+ export { default as IconColorField } from './components/IconColorField.svelte';
29
30
  export { default as AccentSwatches } from './components/AccentSwatches.svelte';
30
31
  export { default as IconButton } from './components/IconButton.svelte';
31
32
  export { default as Input } from './components/Input.svelte';
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ export { default as EmptyState } from './components/EmptyState.svelte';
24
24
  export { default as FontPicker } from './components/FontPicker.svelte';
25
25
  export { default as NavIcon } from './components/NavIcon.svelte';
26
26
  export { default as IconField } from './components/IconField.svelte';
27
+ export { default as IconColorField } from './components/IconColorField.svelte';
27
28
  export { default as AccentSwatches } from './components/AccentSwatches.svelte';
28
29
  export { default as IconButton } from './components/IconButton.svelte';
29
30
  export { default as Input } from './components/Input.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminoir",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Terminoir — Liquid Glass design tokens and Svelte 5 UI components.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",