@poodle64/ui 2026.9.7 → 2026.9.9
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 +24 -13
- package/dist/components/ui/app-shell/app-shell.svelte +134 -2
- package/dist/components/ui/app-shell/controls.d.ts +17 -0
- package/dist/components/ui/app-shell/controls.js +19 -0
- package/dist/components/ui/app-shell/index.d.ts +2 -1
- package/dist/components/ui/app-shell/index.js +2 -1
- package/dist/components/ui/app-shell/shell-controls.svelte +24 -0
- package/dist/components/ui/app-shell/shell-controls.svelte.d.ts +15 -0
- package/dist/components/ui/app-shell/types.d.ts +6 -0
- package/dist/components/ui/app-shell/types.js +17 -0
- package/dist/components/ui/segmented/index.d.ts +2 -0
- package/dist/components/ui/segmented/index.js +4 -0
- package/dist/components/ui/segmented/segmented.svelte +62 -0
- package/dist/components/ui/segmented/segmented.svelte.d.ts +36 -0
- package/dist/styles.css +30 -0
- package/package.json +1 -1
- package/registry/component-map.json +16 -2
- package/registry/component-map.md +3 -1
package/README.md
CHANGED
|
@@ -164,6 +164,28 @@ That is the whole minimum. Everything below is optional.
|
|
|
164
164
|
| `texture` | The house atmosphere on the content region. Defaults to `grid`; `none` is the opt-out. |
|
|
165
165
|
| `padded`, `mainClass` | Padding, and extra classes on the scrolling content container. |
|
|
166
166
|
|
|
167
|
+
The bar also names where you are — the active nav label, read off `nav` and
|
|
168
|
+
`currentPath` — and carries the page's scope controls. A page registers them
|
|
169
|
+
with `ShellControls`; they render after the location, inline from `xl` and on
|
|
170
|
+
a second row of the bar below that:
|
|
171
|
+
|
|
172
|
+
```svelte
|
|
173
|
+
<script lang="ts">
|
|
174
|
+
import { ShellControls } from '@poodle64/ui/app-shell';
|
|
175
|
+
import { Segmented } from '@poodle64/ui/segmented';
|
|
176
|
+
let year = $state('FY26');
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<ShellControls>
|
|
180
|
+
<Segmented bind:value={year} options={YEARS} label="Financial year" size="sm" />
|
|
181
|
+
</ShellControls>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A scope control changes the view of the page you are on and never
|
|
185
|
+
navigates; sub-routes are `NavItem.children`. The rail's right edge is a drag
|
|
186
|
+
handle (200–420px, arrows nudge, Home resets, a drag below 170px collapses);
|
|
187
|
+
the width persists in `localStorage` as `ds-shell-rail-width`.
|
|
188
|
+
|
|
167
189
|
### Nested navigation
|
|
168
190
|
|
|
169
191
|
A section with its own navigation puts it on the item, as `children`, and the
|
|
@@ -1230,17 +1252,6 @@ non-zero and naming the field on any difference.
|
|
|
1230
1252
|
1. Change a component; bump `version` in `package.json` (CalVer).
|
|
1231
1253
|
2. `pnpm build`, which runs `svelte-package` then `publint` (package.json/exports sanity).
|
|
1232
1254
|
3. Commit, tag `ui-v<version>`, push the tag.
|
|
1233
|
-
4. `.github/workflows/publish.yaml` runs on that push and
|
|
1234
|
-
|
|
1235
|
-
`gh run list --workflow=publish.yaml`. As of 2026.9.4 every run since
|
|
1236
|
-
`ui-v2026.9.2` fails at the `npm publish` step with
|
|
1237
|
-
`E404 Not Found - PUT .../@poodle64%2fui` (an OIDC/trusted-publisher
|
|
1238
|
-
binding issue, since `Build and test package` passes first). Until that
|
|
1239
|
-
is diagnosed and fixed, publish manually from `packages/ui/`:
|
|
1240
|
-
```bash
|
|
1241
|
-
printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n' > .npmrc
|
|
1242
|
-
signet exec --identity huginn-claude --broker https://portcullis.godswood.au \
|
|
1243
|
-
--credential npm-publish-token --env-var NODE_AUTH_TOKEN -- npm publish
|
|
1244
|
-
rm -f .npmrc
|
|
1245
|
-
```
|
|
1255
|
+
4. `.github/workflows/publish.yaml` runs on that push and publishes via npm
|
|
1256
|
+
OIDC trusted publishing: `gh run list --workflow=publish.yaml`.
|
|
1246
1257
|
Confirm with `npm view @poodle64/ui version`.
|
|
@@ -89,7 +89,8 @@
|
|
|
89
89
|
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
|
90
90
|
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
|
91
91
|
import AppNav from './app-nav.svelte';
|
|
92
|
-
import { toItems, type NavSource } from './types.js';
|
|
92
|
+
import { activeNavLabel, toItems, type NavSource } from './types.js';
|
|
93
|
+
import { provideShellControls, type ShellControlsSlot } from './controls.js';
|
|
93
94
|
import { cn } from '../../../utils.js';
|
|
94
95
|
|
|
95
96
|
let {
|
|
@@ -214,6 +215,92 @@
|
|
|
214
215
|
|
|
215
216
|
const hasNav = $derived(toItems(nav).length > 0);
|
|
216
217
|
|
|
218
|
+
// Where the user is, in words: the rail is icons when collapsed and a closed
|
|
219
|
+
// drawer on a phone, so the bar names the section.
|
|
220
|
+
const location = $derived(activeNavLabel(nav, currentPath));
|
|
221
|
+
|
|
222
|
+
// The page's scope controls, registered by `<ShellControls>` from the route.
|
|
223
|
+
const controls: ShellControlsSlot = $state({ content: null });
|
|
224
|
+
provideShellControls(controls);
|
|
225
|
+
|
|
226
|
+
// Rail width is a personal preference: localStorage, one key across apps.
|
|
227
|
+
// A drag below RAIL_SNAP collapses; the width survives and returns on expand.
|
|
228
|
+
const RAIL_KEY = 'ds-shell-rail-width';
|
|
229
|
+
const RAIL_MIN = 200;
|
|
230
|
+
const RAIL_MAX = 420;
|
|
231
|
+
const RAIL_SNAP = 170;
|
|
232
|
+
const RAIL_STEP = 16;
|
|
233
|
+
let railWidth = $state<number | null>(null);
|
|
234
|
+
let resizing = $state(false);
|
|
235
|
+
$effect(() => {
|
|
236
|
+
try {
|
|
237
|
+
const stored = Number(localStorage.getItem(RAIL_KEY));
|
|
238
|
+
if (stored >= RAIL_MIN && stored <= RAIL_MAX) railWidth = stored;
|
|
239
|
+
} catch {
|
|
240
|
+
/* storage unavailable: the default width stands */
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
function clampRail(next: number) {
|
|
244
|
+
return Math.min(RAIL_MAX, Math.max(RAIL_MIN, Math.round(next)));
|
|
245
|
+
}
|
|
246
|
+
function setRailWidth(next: number) {
|
|
247
|
+
railWidth = clampRail(next);
|
|
248
|
+
try {
|
|
249
|
+
localStorage.setItem(RAIL_KEY, String(railWidth));
|
|
250
|
+
} catch {
|
|
251
|
+
/* storage unavailable: the width lasts the session */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function startResize(event: PointerEvent) {
|
|
255
|
+
const handle = event.currentTarget as HTMLElement;
|
|
256
|
+
const rail = handle.parentElement as HTMLElement;
|
|
257
|
+
const startX = event.clientX;
|
|
258
|
+
const startWidth = rail.getBoundingClientRect().width;
|
|
259
|
+
const before = railWidth;
|
|
260
|
+
resizing = true;
|
|
261
|
+
handle.setPointerCapture(event.pointerId);
|
|
262
|
+
const move = (e: PointerEvent) => {
|
|
263
|
+
const next = startWidth + (e.clientX - startX);
|
|
264
|
+
if (next < RAIL_SNAP) {
|
|
265
|
+
// A collapse keeps the width it had; only a resize stores one.
|
|
266
|
+
railWidth = before;
|
|
267
|
+
collapsed = true;
|
|
268
|
+
stop();
|
|
269
|
+
} else {
|
|
270
|
+
railWidth = clampRail(next);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const stop = () => {
|
|
274
|
+
resizing = false;
|
|
275
|
+
if (!collapsed && railWidth !== null) setRailWidth(railWidth);
|
|
276
|
+
handle.removeEventListener('pointermove', move);
|
|
277
|
+
handle.removeEventListener('pointerup', stop);
|
|
278
|
+
handle.removeEventListener('pointercancel', stop);
|
|
279
|
+
if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
|
|
280
|
+
};
|
|
281
|
+
handle.addEventListener('pointermove', move);
|
|
282
|
+
handle.addEventListener('pointerup', stop);
|
|
283
|
+
handle.addEventListener('pointercancel', stop);
|
|
284
|
+
}
|
|
285
|
+
function resizeByKey(event: KeyboardEvent) {
|
|
286
|
+
const current = railWidth ?? RAIL_MIN + (RAIL_MAX - RAIL_MIN) / 4;
|
|
287
|
+
if (event.key === 'ArrowLeft') setRailWidth(current - RAIL_STEP);
|
|
288
|
+
else if (event.key === 'ArrowRight') setRailWidth(current + RAIL_STEP);
|
|
289
|
+
else if (event.key === 'Home') resetRailWidth();
|
|
290
|
+
else if (event.key === 'End') setRailWidth(RAIL_MAX);
|
|
291
|
+
else return;
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
}
|
|
294
|
+
/** Back to the package default (Home, or a double-click on the handle). */
|
|
295
|
+
function resetRailWidth() {
|
|
296
|
+
railWidth = null;
|
|
297
|
+
try {
|
|
298
|
+
localStorage.removeItem(RAIL_KEY);
|
|
299
|
+
} catch {
|
|
300
|
+
/* storage unavailable */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
217
304
|
// The measure is resolved ONCE, and the class and the attribute both read
|
|
218
305
|
// that one answer. Two independently-worded conditionals looked symmetric
|
|
219
306
|
// and were not: Svelte omits an attribute whose value is `null`, while a
|
|
@@ -479,7 +566,9 @@
|
|
|
479
566
|
<aside
|
|
480
567
|
id={navRegionId}
|
|
481
568
|
class="ds-shell-rail bg-shell text-shell-foreground border-border flex-none flex-col border-r"
|
|
569
|
+
style={railWidth ? `--ds-shell-rail-width: ${railWidth}px` : undefined}
|
|
482
570
|
data-collapsed={railCollapsed ? 'true' : undefined}
|
|
571
|
+
data-resizing={resizing ? 'true' : undefined}
|
|
483
572
|
data-drawer={mobileNavOpen ? 'true' : undefined}
|
|
484
573
|
data-testid={mobileNavOpen ? 'ds-shell-drawer' : undefined}
|
|
485
574
|
role={mobileNavOpen ? 'dialog' : undefined}
|
|
@@ -540,12 +629,33 @@
|
|
|
540
629
|
label={navLabel}
|
|
541
630
|
onNavigate={() => (mobileNavOpen = false)}
|
|
542
631
|
/>
|
|
632
|
+
{#if collapsible && !railCollapsed && !mobileNavOpen}
|
|
633
|
+
<!-- The rail's right edge: drag to resize, drag past the snap to
|
|
634
|
+
collapse, arrows to nudge, Home to reset, double-click to reset.
|
|
635
|
+
A focusable separator is the ARIA window-splitter pattern. -->
|
|
636
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
637
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
638
|
+
<div
|
|
639
|
+
role="separator"
|
|
640
|
+
aria-orientation="vertical"
|
|
641
|
+
aria-label="Resize sidebar"
|
|
642
|
+
aria-valuenow={railWidth ?? undefined}
|
|
643
|
+
aria-valuemin={RAIL_MIN}
|
|
644
|
+
aria-valuemax={RAIL_MAX}
|
|
645
|
+
tabindex="0"
|
|
646
|
+
class="ds-shell-rail-handle"
|
|
647
|
+
onpointerdown={startResize}
|
|
648
|
+
onkeydown={resizeByKey}
|
|
649
|
+
ondblclick={resetRailWidth}
|
|
650
|
+
data-testid="ds-rail-resize"
|
|
651
|
+
></div>
|
|
652
|
+
{/if}
|
|
543
653
|
</aside>
|
|
544
654
|
{/if}
|
|
545
655
|
|
|
546
656
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
547
657
|
<header
|
|
548
|
-
class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex min-h-14 flex-none items-center gap-2 border-b px-3 pt-[env(safe-area-inset-top)] backdrop-blur sm:gap-3 sm:px-5"
|
|
658
|
+
class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex min-h-14 flex-none flex-wrap items-center gap-2 border-b px-3 pt-[env(safe-area-inset-top)] backdrop-blur sm:gap-3 sm:px-5 xl:flex-nowrap"
|
|
549
659
|
>
|
|
550
660
|
{#if hasNav}
|
|
551
661
|
<!-- `aria-controls` names the region this toggle opens. It is present
|
|
@@ -589,6 +699,28 @@
|
|
|
589
699
|
leading slot that never had to. -->
|
|
590
700
|
{#if context}<div class="min-w-0">{@render context()}</div>{/if}
|
|
591
701
|
|
|
702
|
+
<!-- Where you are, then the page's scope controls. Inline with the
|
|
703
|
+
rest of the bar from xl; below that, controls take a second row
|
|
704
|
+
of the bar, and a bare label shows from md and not on a phone. -->
|
|
705
|
+
{#if location || controls.content}
|
|
706
|
+
<div
|
|
707
|
+
class={cn(
|
|
708
|
+
'flex min-w-0 items-center gap-2 sm:gap-3',
|
|
709
|
+
controls.content
|
|
710
|
+
? 'order-last basis-full pb-2 xl:order-none xl:basis-auto xl:pb-0'
|
|
711
|
+
: 'hidden md:flex'
|
|
712
|
+
)}
|
|
713
|
+
data-testid="ds-shell-location"
|
|
714
|
+
>
|
|
715
|
+
{#if location}
|
|
716
|
+
<span class="text-shell-muted-foreground max-w-48 flex-none truncate text-sm"
|
|
717
|
+
>{location}</span
|
|
718
|
+
>
|
|
719
|
+
{/if}
|
|
720
|
+
{#if controls.content}{@render controls.content()}{/if}
|
|
721
|
+
</div>
|
|
722
|
+
{/if}
|
|
723
|
+
|
|
592
724
|
{#if onSearch && searchPlacement === 'leading'}
|
|
593
725
|
{@render searchButton(
|
|
594
726
|
'h-9 w-9 flex-none justify-center px-0 sm:h-auto sm:w-[clamp(200px,32vw,560px)] sm:flex-1 sm:justify-start sm:gap-2 sm:px-3 sm:py-1.5'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's scope controls in the shell's top bar: what changes the view of
|
|
3
|
+
* the page you are on (an entity, a financial year, chart/table) and never
|
|
4
|
+
* navigates. Sub-routes are the rail's (`NavItem.children`), never here.
|
|
5
|
+
*
|
|
6
|
+
* Controls belong to a route and the shell is composed once above every
|
|
7
|
+
* route, so the shell holds a mutable slot in context; `<ShellControls>` in a
|
|
8
|
+
* page registers its children on mount and withdraws them on unmount. One
|
|
9
|
+
* registration at a time; a later one replaces an earlier one.
|
|
10
|
+
*/
|
|
11
|
+
import { type Snippet } from 'svelte';
|
|
12
|
+
export interface ShellControlsSlot {
|
|
13
|
+
content: Snippet | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function provideShellControls(slot: ShellControlsSlot): void;
|
|
16
|
+
/** Undefined outside a shell: `ShellControls` then renders nothing. */
|
|
17
|
+
export declare function getShellControls(): ShellControlsSlot | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's scope controls in the shell's top bar: what changes the view of
|
|
3
|
+
* the page you are on (an entity, a financial year, chart/table) and never
|
|
4
|
+
* navigates. Sub-routes are the rail's (`NavItem.children`), never here.
|
|
5
|
+
*
|
|
6
|
+
* Controls belong to a route and the shell is composed once above every
|
|
7
|
+
* route, so the shell holds a mutable slot in context; `<ShellControls>` in a
|
|
8
|
+
* page registers its children on mount and withdraws them on unmount. One
|
|
9
|
+
* registration at a time; a later one replaces an earlier one.
|
|
10
|
+
*/
|
|
11
|
+
import { getContext, setContext } from 'svelte';
|
|
12
|
+
const KEY = Symbol('ds-shell-controls');
|
|
13
|
+
export function provideShellControls(slot) {
|
|
14
|
+
setContext(KEY, slot);
|
|
15
|
+
}
|
|
16
|
+
/** Undefined outside a shell: `ShellControls` then renders nothing. */
|
|
17
|
+
export function getShellControls() {
|
|
18
|
+
return getContext(KEY);
|
|
19
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { default as AppShell } from './app-shell.svelte';
|
|
2
2
|
export { default as AppNav } from './app-nav.svelte';
|
|
3
|
+
export { default as ShellControls } from './shell-controls.svelte';
|
|
3
4
|
export { default } from './app-shell.svelte';
|
|
4
5
|
export { SHELL_MEASURES, type ShellMeasure } from './measure.js';
|
|
5
6
|
export { SHELL_TEXTURES, type ShellTexture } from './texture.js';
|
|
6
|
-
export { hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems, type IconComponent, type NavChildItem, type NavEntry, type NavGroup, type NavItem, type NavSource } from './types.js';
|
|
7
|
+
export { activeNavLabel, hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems, type IconComponent, type NavChildItem, type NavEntry, type NavGroup, type NavItem, type NavSource } from './types.js';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { default as AppShell } from './app-shell.svelte';
|
|
2
2
|
export { default as AppNav } from './app-nav.svelte';
|
|
3
|
+
export { default as ShellControls } from './shell-controls.svelte';
|
|
3
4
|
export { default } from './app-shell.svelte';
|
|
4
5
|
export { SHELL_MEASURES } from './measure.js';
|
|
5
6
|
export { SHELL_TEXTURES } from './texture.js';
|
|
6
|
-
export { hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems } from './types.js';
|
|
7
|
+
export { activeNavLabel, hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems } from './types.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Registers this page's controls in the shell's top bar. Renders nothing
|
|
4
|
+
* where it stands.
|
|
5
|
+
*
|
|
6
|
+
* <ShellControls>
|
|
7
|
+
* <Segmented bind:value={fy} options={years} label="Financial year" />
|
|
8
|
+
* </ShellControls>
|
|
9
|
+
*/
|
|
10
|
+
import type { Snippet } from 'svelte';
|
|
11
|
+
import { getShellControls } from './controls.js';
|
|
12
|
+
|
|
13
|
+
let { children }: { children: Snippet } = $props();
|
|
14
|
+
|
|
15
|
+
const slot = getShellControls();
|
|
16
|
+
|
|
17
|
+
$effect(() => {
|
|
18
|
+
if (!slot) return;
|
|
19
|
+
slot.content = children;
|
|
20
|
+
return () => {
|
|
21
|
+
if (slot.content === children) slot.content = null;
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers this page's controls in the shell's top bar. Renders nothing
|
|
3
|
+
* where it stands.
|
|
4
|
+
*
|
|
5
|
+
* <ShellControls>
|
|
6
|
+
* <Segmented bind:value={fy} options={years} label="Financial year" />
|
|
7
|
+
* </ShellControls>
|
|
8
|
+
*/
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
type $$ComponentProps = {
|
|
11
|
+
children: Snippet;
|
|
12
|
+
};
|
|
13
|
+
declare const ShellControls: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type ShellControls = ReturnType<typeof ShellControls>;
|
|
15
|
+
export default ShellControls;
|
|
@@ -149,3 +149,9 @@ export declare function isNavItemActive(item: NavItem, currentPath: string | und
|
|
|
149
149
|
* when the children live at their own top-level routes.
|
|
150
150
|
*/
|
|
151
151
|
export declare function hasActiveNavChild(item: NavItem, currentPath: string | undefined): boolean;
|
|
152
|
+
/**
|
|
153
|
+
* The label of where the user is: the active child's where one matches, else
|
|
154
|
+
* the active item's. Undefined when nothing matches, so an unlisted route
|
|
155
|
+
* renders no label rather than a wrong one.
|
|
156
|
+
*/
|
|
157
|
+
export declare function activeNavLabel(nav: NavSource | undefined, currentPath: string | undefined): string | undefined;
|
|
@@ -110,3 +110,20 @@ export function hasActiveNavChild(item, currentPath) {
|
|
|
110
110
|
return false;
|
|
111
111
|
return navChildren(item).some((child) => isNavItemActive(child, currentPath));
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The label of where the user is: the active child's where one matches, else
|
|
115
|
+
* the active item's. Undefined when nothing matches, so an unlisted route
|
|
116
|
+
* renders no label rather than a wrong one.
|
|
117
|
+
*/
|
|
118
|
+
export function activeNavLabel(nav, currentPath) {
|
|
119
|
+
if (!currentPath)
|
|
120
|
+
return undefined;
|
|
121
|
+
for (const item of toItems(nav)) {
|
|
122
|
+
const child = navChildren(item).find((c) => isNavItemActive(c, currentPath));
|
|
123
|
+
if (child)
|
|
124
|
+
return child.label;
|
|
125
|
+
if (isNavItemActive(item, currentPath))
|
|
126
|
+
return item.label;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface SegmentedOption<T extends string = string> {
|
|
3
|
+
value: T;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<script lang="ts" generics="T extends string">
|
|
9
|
+
/**
|
|
10
|
+
* One value from a small fixed set, every option visible: the scope toggle
|
|
11
|
+
* in the shell's top bar. Past about five options, use Select. A scope
|
|
12
|
+
* always has one answer, so the value cannot be cleared.
|
|
13
|
+
*/
|
|
14
|
+
import { ToggleGroup } from 'bits-ui';
|
|
15
|
+
import { cn } from '../../../utils.js';
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
value = $bindable(),
|
|
19
|
+
options,
|
|
20
|
+
label,
|
|
21
|
+
size = 'default',
|
|
22
|
+
class: className
|
|
23
|
+
}: {
|
|
24
|
+
value: T;
|
|
25
|
+
options: readonly SegmentedOption<T>[];
|
|
26
|
+
/** The control's accessible name: what the set is a choice of. */
|
|
27
|
+
label: string;
|
|
28
|
+
/** `sm` is the top-bar height; `default` sits in a page's own control row. */
|
|
29
|
+
size?: 'default' | 'sm';
|
|
30
|
+
class?: string;
|
|
31
|
+
} = $props();
|
|
32
|
+
|
|
33
|
+
const ITEM = {
|
|
34
|
+
default: 'h-8 px-3 text-sm',
|
|
35
|
+
sm: 'h-6 px-2 text-xs'
|
|
36
|
+
} as const;
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<ToggleGroup.Root
|
|
40
|
+
type="single"
|
|
41
|
+
bind:value={() => value, (v) => { if (v) value = v as T; }}
|
|
42
|
+
aria-label={label}
|
|
43
|
+
class={cn(
|
|
44
|
+
'border-border bg-surface-1 inline-flex flex-none items-center gap-0.5 rounded-md border p-0.5',
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
data-slot="segmented"
|
|
48
|
+
>
|
|
49
|
+
{#each options as option (option.value)}
|
|
50
|
+
<ToggleGroup.Item
|
|
51
|
+
value={option.value}
|
|
52
|
+
class={cn(
|
|
53
|
+
'text-muted-foreground hover:text-foreground rounded-sm font-medium whitespace-nowrap transition-colors',
|
|
54
|
+
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
|
55
|
+
'data-[state=on]:bg-surface-3 data-[state=on]:text-foreground data-[state=on]:font-semibold',
|
|
56
|
+
ITEM[size]
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{option.label}
|
|
60
|
+
</ToggleGroup.Item>
|
|
61
|
+
{/each}
|
|
62
|
+
</ToggleGroup.Root>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SegmentedOption<T extends string = string> {
|
|
2
|
+
value: T;
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
declare function $$render<T extends string>(): {
|
|
6
|
+
props: {
|
|
7
|
+
value: T;
|
|
8
|
+
options: readonly SegmentedOption<T>[];
|
|
9
|
+
/** The control's accessible name: what the set is a choice of. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** `sm` is the top-bar height; `default` sits in a page's own control row. */
|
|
12
|
+
size?: "default" | "sm";
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
exports: {};
|
|
16
|
+
bindings: "value";
|
|
17
|
+
slots: {};
|
|
18
|
+
events: {};
|
|
19
|
+
};
|
|
20
|
+
declare class __sveltets_Render<T extends string> {
|
|
21
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
22
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
23
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
24
|
+
bindings(): "value";
|
|
25
|
+
exports(): {};
|
|
26
|
+
}
|
|
27
|
+
interface $$IsomorphicComponent {
|
|
28
|
+
new <T extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
29
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
30
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
31
|
+
<T extends string>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
32
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
33
|
+
}
|
|
34
|
+
declare const Segmented: $$IsomorphicComponent;
|
|
35
|
+
type Segmented<T extends string> = InstanceType<typeof Segmented<T>>;
|
|
36
|
+
export default Segmented;
|
package/dist/styles.css
CHANGED
|
@@ -952,6 +952,36 @@
|
|
|
952
952
|
width: var(--ds-shell-rail-width-collapsed);
|
|
953
953
|
}
|
|
954
954
|
|
|
955
|
+
/* Width follows the pointer during a drag; the transition is for the
|
|
956
|
+
collapse, not for every pixel of a resize. */
|
|
957
|
+
.ds-shell-rail[data-resizing='true'] {
|
|
958
|
+
transition: none;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/* The resize handle: a 6px grab strip astride the rail's right edge, md+
|
|
962
|
+
only, showing the border colour under the pointer and keyboard focus. */
|
|
963
|
+
.ds-shell-rail-handle {
|
|
964
|
+
display: none;
|
|
965
|
+
}
|
|
966
|
+
@media (min-width: 48rem) {
|
|
967
|
+
.ds-shell-rail-handle {
|
|
968
|
+
display: block;
|
|
969
|
+
position: absolute;
|
|
970
|
+
inset-block: 0;
|
|
971
|
+
right: -3px;
|
|
972
|
+
width: 6px;
|
|
973
|
+
cursor: col-resize;
|
|
974
|
+
z-index: 1;
|
|
975
|
+
touch-action: none;
|
|
976
|
+
}
|
|
977
|
+
.ds-shell-rail-handle:hover,
|
|
978
|
+
.ds-shell-rail-handle:focus-visible {
|
|
979
|
+
background: var(--ds-color-primary);
|
|
980
|
+
opacity: 0.4;
|
|
981
|
+
outline: none;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
955
985
|
/* The content measure: the shell's content box is ONE element in four
|
|
956
986
|
states, the same shape as the rail above it.
|
|
957
987
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/ui",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.9",
|
|
4
4
|
"description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"package": "@poodle64/ui",
|
|
4
|
-
"version": "2026.9.
|
|
4
|
+
"version": "2026.9.9",
|
|
5
5
|
"generatedBy": "scripts/generate-registry.mjs",
|
|
6
6
|
"source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
|
|
7
|
-
"componentCount":
|
|
7
|
+
"componentCount": 57,
|
|
8
8
|
"situationCount": 15
|
|
9
9
|
},
|
|
10
10
|
"situations": [
|
|
@@ -286,6 +286,20 @@
|
|
|
286
286
|
"props": "nav, currentPath, brandTitle?, actions?, context?, children",
|
|
287
287
|
"insteadOf": "a bespoke nav rail + header layout per app"
|
|
288
288
|
},
|
|
289
|
+
{
|
|
290
|
+
"name": "ShellControls",
|
|
291
|
+
"dir": "app-shell",
|
|
292
|
+
"import": "@poodle64/ui/app-shell",
|
|
293
|
+
"props": "children",
|
|
294
|
+
"insteadOf": "a scope row (entity, financial year, chart/table) hand-placed above the page body; the shell's top bar carries it"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"name": "Segmented",
|
|
298
|
+
"dir": "segmented",
|
|
299
|
+
"import": "@poodle64/ui/segmented",
|
|
300
|
+
"props": "value, options, label, size?",
|
|
301
|
+
"insteadOf": "a hand-built row of aria-pressed buttons for a small fixed choice"
|
|
302
|
+
},
|
|
289
303
|
{
|
|
290
304
|
"name": "PageHeader",
|
|
291
305
|
"dir": "page-header",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @poodle64/ui — situation → component map
|
|
2
2
|
|
|
3
3
|
<!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
|
|
4
|
-
Generated from `@poodle64/ui@2026.9.
|
|
4
|
+
Generated from `@poodle64/ui@2026.9.9`. 57 components, 15 situations.
|
|
5
5
|
|
|
6
6
|
**Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
|
|
7
7
|
|
|
@@ -125,6 +125,8 @@ _The standing chrome — the app shell and its nav, the one page-title treatment
|
|
|
125
125
|
| Component | Import | Key props | Reach for it instead of |
|
|
126
126
|
| --- | --- | --- | --- |
|
|
127
127
|
| `AppShell` | `@poodle64/ui/app-shell` | `nav, currentPath, brandTitle?, actions?, context?, children` | a bespoke nav rail + header layout per app |
|
|
128
|
+
| `ShellControls` | `@poodle64/ui/app-shell` | `children` | a scope row (entity, financial year, chart/table) hand-placed above the page body; the shell's top bar carries it |
|
|
129
|
+
| `Segmented` | `@poodle64/ui/segmented` | `value, options, label, size?` | a hand-built row of aria-pressed buttons for a small fixed choice |
|
|
128
130
|
| `PageHeader` | `@poodle64/ui/page-header` | `title?, eyebrow?, breadcrumbs?, icon?, subtitle?, info?, meta?, actions?` | a hand-written <h1> and title bar (the drift gate fails this) |
|
|
129
131
|
| `ContextColumn` | `@poodle64/ui/context-column` | `stats, statsTitle?, statsInfo?, detail?, ariaLabel?` | a hand-built right-hand <aside> with a stat card and a detail pane |
|
|
130
132
|
| `AppIdentity` | `@poodle64/ui/app-identity` | `user, workspace?, role?, entitlements?, onSignOut, onAccountSettings?, onSwitchTheme?` | a hand-rolled account/avatar dropdown wired to raw DropdownMenu.* + Avatar.* |
|