terminoir 0.16.0 → 0.18.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
 
@@ -156,6 +157,7 @@ Building blocks so apps compose screens without CSS of their own. See the demo's
156
157
  | `List`, `ListItem`, `DescriptionList`, `FormRow` | Rows with leading/trailing slots; key–value pairs; label-left form rows |
157
158
  | `SearchPicker` | Debounced async combobox (`search(q)`), keyboard navigation; grows to fill a row after chips |
158
159
  | `MarkdownEditor` | Notion-style editor that reads and writes markdown (Milkdown): `/` block menu, block handle (`+` and drag to move), checklists, markdown shortcuts, undo. `linkSearch` enables `[[` links written as `[[id\|label]]`; `onlinkopen` gets the clicked target. Outside `value` changes apply while it is not focused; `onchange` fires only after a user edit |
160
+ | `AsideLayout` | Main column with a collapsible right-hand panel (e.g. a record's properties); remembers open/closed, stacks inline under the header on phones |
159
161
  | `PropertyRow` | Compact icon + label row for inline-edited values (record properties); hover highlights the row |
160
162
  | `ChipSelect`, `DateField` | Value shown as a coloured `Chip` that opens a grouped option list (`ChipSelectOption`, `ChipSelectGroup`); date shown as "Today" / "Tomorrow" / weekday / "Nov 12" (`dayLabel`), `deadline` tints past and today, opens the native picker, Backspace clears |
161
163
  | `TitleInput`, `FileButton`, `RadioCards` | Borderless heading input; button that opens a file picker; card-style radio group |
@@ -0,0 +1,145 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+ import { readStorage, writeStorage } from '../utils/storage.js';
5
+ import NavIcon from './NavIcon.svelte';
6
+
7
+ let {
8
+ label,
9
+ appId = 'app',
10
+ storageKey = 'aside-open',
11
+ class: className = '',
12
+ header,
13
+ aside,
14
+ children,
15
+ }: {
16
+ /** Names the side panel, e.g. "Properties"; shown as its heading and on the toggle. */
17
+ label: string;
18
+ appId?: string;
19
+ /** Where the open/closed choice is remembered; give each kind of screen its own key. */
20
+ storageKey?: string;
21
+ class?: string;
22
+ /** Top of the main column (toolbar, title). On phones it stays above the panel. */
23
+ header?: Snippet;
24
+ /** Panel content: a right-hand column on wide screens, inline under `header` on phones. */
25
+ aside?: Snippet;
26
+ children?: Snippet;
27
+ } = $props();
28
+
29
+ let open = $state(true);
30
+
31
+ $effect.pre(() => {
32
+ open = readStorage(appId, storageKey, 'true') !== 'false';
33
+ });
34
+
35
+ function toggle() {
36
+ open = !open;
37
+ writeStorage(appId, storageKey, String(open));
38
+ }
39
+ </script>
40
+
41
+ <div class={cn('terminoir-aside-layout', !open && 'terminoir-aside-layout--collapsed', className)}>
42
+ <div class="terminoir-aside-layout__header">
43
+ {@render header?.()}
44
+ </div>
45
+ <aside class="terminoir-aside-layout__aside" aria-label={label}>
46
+ <div class="terminoir-aside-layout__aside-top">
47
+ <span class="terminoir-aside-layout__label">{label}</span>
48
+ <button
49
+ type="button"
50
+ class="terminoir-btn terminoir-btn--sm terminoir-btn--ghost terminoir-focusable glass-interactive terminoir-aside-layout__toggle"
51
+ aria-label={open ? `Hide ${label.toLowerCase()}` : `Show ${label.toLowerCase()}`}
52
+ aria-expanded={open}
53
+ title={open ? `Hide ${label.toLowerCase()}` : `Show ${label.toLowerCase()}`}
54
+ onclick={toggle}
55
+ >
56
+ <NavIcon name={open ? 'angles-right' : 'angles-left'} />
57
+ </button>
58
+ </div>
59
+ <div class="terminoir-aside-layout__aside-body">
60
+ {@render aside?.()}
61
+ </div>
62
+ </aside>
63
+ <div class="terminoir-aside-layout__main">
64
+ {@render children?.()}
65
+ </div>
66
+ </div>
67
+
68
+ <style>
69
+ .terminoir-aside-layout {
70
+ --terminoir-aside-width: 20rem;
71
+ display: grid;
72
+ grid-template-columns: minmax(0, 1fr) var(--terminoir-aside-width);
73
+ grid-template-rows: auto 1fr;
74
+ grid-template-areas:
75
+ 'header aside'
76
+ 'main aside';
77
+ column-gap: var(--space-6);
78
+ row-gap: var(--space-4);
79
+ min-width: 0;
80
+ }
81
+ .terminoir-aside-layout--collapsed {
82
+ grid-template-columns: minmax(0, 1fr) auto;
83
+ }
84
+ .terminoir-aside-layout__header,
85
+ .terminoir-aside-layout__main {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: var(--space-4);
89
+ min-width: 0;
90
+ }
91
+ .terminoir-aside-layout__header { grid-area: header; }
92
+ .terminoir-aside-layout__main { grid-area: main; }
93
+ .terminoir-aside-layout__aside {
94
+ grid-area: aside;
95
+ align-self: start;
96
+ position: sticky;
97
+ top: var(--space-4);
98
+ max-height: calc(100dvh - var(--space-8));
99
+ overflow-y: auto;
100
+ min-width: 0;
101
+ padding-left: var(--space-4);
102
+ border-left: 1px solid var(--border);
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: var(--space-2);
106
+ }
107
+ .terminoir-aside-layout__aside-top {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ gap: var(--space-2);
112
+ }
113
+ .terminoir-aside-layout__label {
114
+ font-size: 0.75rem;
115
+ font-weight: 600;
116
+ color: var(--muted-foreground);
117
+ }
118
+ .terminoir-aside-layout--collapsed .terminoir-aside-layout__aside {
119
+ padding-left: var(--space-2);
120
+ }
121
+ .terminoir-aside-layout--collapsed .terminoir-aside-layout__label,
122
+ .terminoir-aside-layout--collapsed .terminoir-aside-layout__aside-body {
123
+ display: none;
124
+ }
125
+
126
+ /* Phones: one column, the panel inline under the header and always open. */
127
+ @media (max-width: 768px) {
128
+ .terminoir-aside-layout,
129
+ .terminoir-aside-layout--collapsed {
130
+ grid-template-columns: minmax(0, 1fr);
131
+ grid-template-rows: auto;
132
+ grid-template-areas: 'header' 'aside' 'main';
133
+ }
134
+ .terminoir-aside-layout__aside,
135
+ .terminoir-aside-layout--collapsed .terminoir-aside-layout__aside {
136
+ position: static;
137
+ max-height: none;
138
+ overflow: visible;
139
+ padding-left: 0;
140
+ border-left: 0;
141
+ }
142
+ .terminoir-aside-layout__aside-top { display: none; }
143
+ .terminoir-aside-layout--collapsed .terminoir-aside-layout__aside-body { display: block; }
144
+ }
145
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Names the side panel, e.g. "Properties"; shown as its heading and on the toggle. */
4
+ label: string;
5
+ appId?: string;
6
+ /** Where the open/closed choice is remembered; give each kind of screen its own key. */
7
+ storageKey?: string;
8
+ class?: string;
9
+ /** Top of the main column (toolbar, title). On phones it stays above the panel. */
10
+ header?: Snippet;
11
+ /** Panel content: a right-hand column on wide screens, inline under `header` on phones. */
12
+ aside?: Snippet;
13
+ children?: Snippet;
14
+ };
15
+ declare const AsideLayout: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type AsideLayout = ReturnType<typeof AsideLayout>;
17
+ export default AsideLayout;
@@ -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';
@@ -42,6 +43,7 @@ export { default as Toaster } from './components/Toaster.svelte';
42
43
  export { default as Tooltip } from './components/Tooltip.svelte';
43
44
  export { default as Page } from './components/Page.svelte';
44
45
  export { default as PageHeader } from './components/PageHeader.svelte';
46
+ export { default as AsideLayout } from './components/AsideLayout.svelte';
45
47
  export { default as PanelHeader } from './components/PanelHeader.svelte';
46
48
  export { default as Section } from './components/Section.svelte';
47
49
  export { default as Stack } from './components/Stack.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';
@@ -41,6 +42,7 @@ export { default as Tooltip } from './components/Tooltip.svelte';
41
42
  // Layout
42
43
  export { default as Page } from './components/Page.svelte';
43
44
  export { default as PageHeader } from './components/PageHeader.svelte';
45
+ export { default as AsideLayout } from './components/AsideLayout.svelte';
44
46
  export { default as PanelHeader } from './components/PanelHeader.svelte';
45
47
  export { default as Section } from './components/Section.svelte';
46
48
  export { default as Stack } from './components/Stack.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminoir",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Terminoir — Liquid Glass design tokens and Svelte 5 UI components.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",