@tristan2828/ui-foundation 4.62.0 → 4.63.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.
- package/conventions/docs/design-language.md +140 -23
- package/dist/components/app/app-shell.js +28 -2
- package/dist/components/app/board.js +1 -1
- package/dist/components/app/data-table.js +1 -1
- package/dist/components/app/foundation-providers.d.ts +14 -1
- package/dist/components/app/foundation-providers.js +4 -3
- package/dist/components/app/module-card.js +1 -1
- package/dist/components/app/palette-provider.d.ts +14 -0
- package/dist/components/app/palette-provider.js +35 -0
- package/dist/components/app/widget-card.js +2 -2
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/badge.js +5 -2
- package/dist/components/ui/button.js +10 -10
- package/dist/components/ui/card.js +3 -3
- package/dist/components/ui/checkbox.js +1 -1
- package/dist/components/ui/combobox.js +1 -1
- package/dist/components/ui/dialog.js +1 -1
- package/dist/components/ui/input-group.js +1 -1
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/progress.d.ts +7 -0
- package/dist/components/ui/progress.js +19 -0
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/sheet.js +1 -1
- package/dist/components/ui/slider.d.ts +3 -0
- package/dist/components/ui/slider.js +12 -0
- package/dist/components/ui/textarea.js +1 -1
- package/dist/hooks/use-palette.d.ts +14 -0
- package/dist/hooks/use-palette.js +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/palettes.d.ts +21 -0
- package/dist/lib/palettes.js +74 -0
- package/dist/testing/index.d.ts +14 -3
- package/dist/testing/index.js +44 -14
- package/package.json +1 -1
- package/styles/index.css +60 -0
- package/styles/theme.css +321 -11
- package/vite/index.d.ts +7 -1
- package/vite/pwa.js +21 -7
|
@@ -3,6 +3,6 @@ import * as React from "react";
|
|
|
3
3
|
import { Input as InputPrimitive } from "@base-ui/react/input";
|
|
4
4
|
import { cn } from "cn";
|
|
5
5
|
function Input({ className, type, ...props }) {
|
|
6
|
-
return (_jsx(InputPrimitive, { type: type, "data-slot": "input", className: cn("h-
|
|
6
|
+
return (_jsx(InputPrimitive, { type: type, "data-slot": "input", className: cn("h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
|
|
7
7
|
}
|
|
8
8
|
export { Input };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
|
|
2
|
+
declare function Progress({ className, children, value, ...props }: ProgressPrimitive.Root.Props): import("react").JSX.Element;
|
|
3
|
+
declare function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props): import("react").JSX.Element;
|
|
4
|
+
declare function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props): import("react").JSX.Element;
|
|
5
|
+
declare function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props): import("react").JSX.Element;
|
|
6
|
+
declare function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props): import("react").JSX.Element;
|
|
7
|
+
export { Progress, ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue, };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
|
|
3
|
+
import { cn } from "cn";
|
|
4
|
+
function Progress({ className, children, value, ...props }) {
|
|
5
|
+
return (_jsxs(ProgressPrimitive.Root, { value: value, "data-slot": "progress", className: cn("flex flex-wrap gap-3", className), ...props, children: [children, _jsx(ProgressTrack, { children: _jsx(ProgressIndicator, {}) })] }));
|
|
6
|
+
}
|
|
7
|
+
function ProgressTrack({ className, ...props }) {
|
|
8
|
+
return (_jsx(ProgressPrimitive.Track, { className: cn("relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted", className), "data-slot": "progress-track", ...props }));
|
|
9
|
+
}
|
|
10
|
+
function ProgressIndicator({ className, ...props }) {
|
|
11
|
+
return (_jsx(ProgressPrimitive.Indicator, { "data-slot": "progress-indicator", className: cn("h-full bg-primary transition-all", className), ...props }));
|
|
12
|
+
}
|
|
13
|
+
function ProgressLabel({ className, ...props }) {
|
|
14
|
+
return (_jsx(ProgressPrimitive.Label, { className: cn("text-sm font-medium", className), "data-slot": "progress-label", ...props }));
|
|
15
|
+
}
|
|
16
|
+
function ProgressValue({ className, ...props }) {
|
|
17
|
+
return (_jsx(ProgressPrimitive.Value, { className: cn("ml-auto text-sm text-muted-foreground tabular-nums", className), "data-slot": "progress-value", ...props }));
|
|
18
|
+
}
|
|
19
|
+
export { Progress, ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue, };
|
|
@@ -12,7 +12,7 @@ function SelectValue({ className, ...props }) {
|
|
|
12
12
|
return (_jsx(SelectPrimitive.Value, { "data-slot": "select-value", className: cn("flex flex-1 text-left", className), ...props }));
|
|
13
13
|
}
|
|
14
14
|
function SelectTrigger({ className, size = "default", children, ...props }) {
|
|
15
|
-
return (_jsxs(SelectPrimitive.Trigger, { "data-slot": "select-trigger", "data-size": size, className: cn("flex w-fit items-center justify-between gap-1.5 rounded-
|
|
15
|
+
return (_jsxs(SelectPrimitive.Trigger, { "data-slot": "select-trigger", "data-size": size, className: cn("flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [children, _jsx(SelectPrimitive.Icon, { render: _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" }) })] }));
|
|
16
16
|
}
|
|
17
17
|
function SelectContent({ className, children, side = "bottom", sideOffset = 4, align = "center", alignOffset = 0, alignItemWithTrigger = true, ...props }) {
|
|
18
18
|
return (_jsx(SelectPrimitive.Portal, { children: _jsx(SelectPrimitive.Positioner, { side: side, sideOffset: sideOffset, align: align, alignOffset: alignOffset, alignItemWithTrigger: alignItemWithTrigger, className: "isolate z-50", children: _jsxs(SelectPrimitive.Popup, { "data-slot": "select-content", "data-align-trigger": alignItemWithTrigger, className: cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props, children: [_jsx(SelectScrollUpButton, {}), _jsx(SelectPrimitive.List, { children: children }), _jsx(SelectScrollDownButton, {})] }) }) }));
|
|
@@ -18,7 +18,7 @@ function SheetPortal({ ...props }) {
|
|
|
18
18
|
return _jsx(SheetPrimitive.Portal, { "data-slot": "sheet-portal", ...props });
|
|
19
19
|
}
|
|
20
20
|
function SheetOverlay({ className, ...props }) {
|
|
21
|
-
return (_jsx(SheetPrimitive.Backdrop, { "data-slot": "sheet-overlay", className: cn("fixed inset-0 z-50 bg-black/
|
|
21
|
+
return (_jsx(SheetPrimitive.Backdrop, { "data-slot": "sheet-overlay", className: cn("fixed inset-0 z-50 bg-black/20 transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-sm dark:bg-black/40", className), ...props }));
|
|
22
22
|
}
|
|
23
23
|
function SheetContent({ className, children, side = "right", showCloseButton = true, ...props }) {
|
|
24
24
|
return (_jsxs(SheetPortal, { children: [_jsx(SheetOverlay, {}), _jsxs(SheetPrimitive.Popup, { "data-slot": "sheet-content", "data-side": side, className: cn("fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className), ...props, children: [children, showCloseButton && (_jsxs(SheetPrimitive.Close, { "data-slot": "sheet-close", render: _jsx(Button, { variant: "ghost", className: "absolute top-3 right-3", size: "icon-sm" }), children: [_jsx(XIcon, {}), _jsx("span", { className: "sr-only", children: "Close" })] }))] })] }));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
|
|
3
|
+
import { cn } from "cn";
|
|
4
|
+
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }) {
|
|
5
|
+
const _values = Array.isArray(value)
|
|
6
|
+
? value
|
|
7
|
+
: Array.isArray(defaultValue)
|
|
8
|
+
? defaultValue
|
|
9
|
+
: [min, max];
|
|
10
|
+
return (_jsx(SliderPrimitive.Root, { className: cn("data-horizontal:w-full data-vertical:h-full", className), "data-slot": "slider", defaultValue: defaultValue, value: value, min: min, max: max, thumbAlignment: "edge", ...props, children: _jsxs(SliderPrimitive.Control, { className: "relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col", children: [_jsx(SliderPrimitive.Track, { "data-slot": "slider-track", className: "relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1", children: _jsx(SliderPrimitive.Indicator, { "data-slot": "slider-range", className: "bg-primary select-none data-horizontal:h-full data-vertical:w-full" }) }), Array.from({ length: _values.length }, (_, index) => (_jsx(SliderPrimitive.Thumb, { "data-slot": "slider-thumb", className: "relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] palette:shadow-(--thumb-glow) select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50" }, index)))] }) }));
|
|
11
|
+
}
|
|
12
|
+
export { Slider };
|
|
@@ -2,6 +2,6 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { cn } from "cn";
|
|
4
4
|
function Textarea({ className, ...props }) {
|
|
5
|
-
return (_jsx("textarea", { "data-slot": "textarea", className: cn("flex field-sizing-content min-h-16 w-full rounded-
|
|
5
|
+
return (_jsx("textarea", { "data-slot": "textarea", className: cn("flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
|
|
6
6
|
}
|
|
7
7
|
export { Textarea };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PaletteName } from '../lib/palettes.js';
|
|
2
|
+
export type PaletteState = {
|
|
3
|
+
/** The palette on screen, or null for an app without one (light and dark as the system says). */
|
|
4
|
+
palette: PaletteName | null;
|
|
5
|
+
/** The app's own default. */
|
|
6
|
+
defaultPalette: PaletteName | null;
|
|
7
|
+
/** Whether this app lets a device pick (FoundationProviders' `palettePicker`). */
|
|
8
|
+
picker: boolean;
|
|
9
|
+
/** Saves `palette` as this device's choice. Does nothing without the picker. */
|
|
10
|
+
setPalette: (palette: PaletteName) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare const PaletteContext: import("react").Context<PaletteState>;
|
|
13
|
+
/** The palette on screen and, with the picker on, a way to change it on this device. */
|
|
14
|
+
export declare function usePalette(): PaletteState;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
const NO_PALETTE = { palette: null, defaultPalette: null, picker: false, setPalette: () => { } };
|
|
3
|
+
export const PaletteContext = createContext(NO_PALETTE);
|
|
4
|
+
/** The palette on screen and, with the picker on, a way to change it on this device. */
|
|
5
|
+
export function usePalette() {
|
|
6
|
+
return useContext(PaletteContext);
|
|
7
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
|
76
76
|
export { useSessionClock, type SessionClock, type SessionClockOptions, type SessionClockState, type SessionStatus, } from './hooks/use-session-clock.js';
|
|
77
77
|
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
78
78
|
export { useMuted } from './hooks/use-muted.js';
|
|
79
|
+
export { usePalette, type PaletteState } from './hooks/use-palette.js';
|
|
80
|
+
export { PALETTE_LABELS, PALETTE_STORAGE_KEY, PALETTES, isPaletteName, type PaletteName } from './lib/palettes.js';
|
|
79
81
|
export { createPushController, usePushPermission, type PushController, type PushControllerOptions, type PushEnvironment, type PushPermission, type PushStatus, } from './hooks/use-push-permission.js';
|
|
80
82
|
export { cn } from './lib/utils.js';
|
|
81
83
|
export { canVibrate, cancelCues, chime, cuesInSync, hush, onCuesRecovered, say, sequence, unlockAudio, vibrate, type ChimeOptions, type Cue, type CueTime, type SayOptions, type SequenceOptions, } from './lib/audio-cues.js';
|
package/dist/index.js
CHANGED
|
@@ -79,6 +79,8 @@ export { useWakeLock } from './hooks/use-wake-lock.js';
|
|
|
79
79
|
export { useSessionClock, } from './hooks/use-session-clock.js';
|
|
80
80
|
export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion.js';
|
|
81
81
|
export { useMuted } from './hooks/use-muted.js';
|
|
82
|
+
export { usePalette } from './hooks/use-palette.js';
|
|
83
|
+
export { PALETTE_LABELS, PALETTE_STORAGE_KEY, PALETTES, isPaletteName } from './lib/palettes.js';
|
|
82
84
|
export { createPushController, usePushPermission, } from './hooks/use-push-permission.js';
|
|
83
85
|
// Utilities
|
|
84
86
|
export { cn } from './lib/utils.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const PALETTES: readonly ["indigo-cyan", "indigo-rose", "graphite-periwinkle", "violet"];
|
|
2
|
+
export type PaletteName = (typeof PALETTES)[number];
|
|
3
|
+
/** Each palette's name as a person reads it, in the guide's ranked order. */
|
|
4
|
+
export declare const PALETTE_LABELS: Record<PaletteName, string>;
|
|
5
|
+
/** Where a device's chosen palette is kept. */
|
|
6
|
+
export declare const PALETTE_STORAGE_KEY = "ui-foundation:palette";
|
|
7
|
+
export declare function isPaletteName(value: unknown): value is PaletteName;
|
|
8
|
+
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
|
9
|
+
export type PaletteStore = {
|
|
10
|
+
/** The saved choice, or null when there is none (or storage is blocked, or it names no palette). */
|
|
11
|
+
get: () => PaletteName | null;
|
|
12
|
+
set: (palette: PaletteName) => void;
|
|
13
|
+
subscribe: (listener: () => void) => () => void;
|
|
14
|
+
/** Another tab saved a choice: forget the in-memory one and re-read. */
|
|
15
|
+
changedElsewhere: () => void;
|
|
16
|
+
};
|
|
17
|
+
/** The store under usePalette, taking the storage so its rules are unit tested (tests/palettes.test.ts). */
|
|
18
|
+
export declare function createPaletteStore(getStorage: () => StorageLike | undefined): PaletteStore;
|
|
19
|
+
export declare const browserPaletteStore: PaletteStore;
|
|
20
|
+
export declare function subscribeToBrowserPaletteStore(listener: () => void): () => void;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The four palettes (design-language.md "Palettes"): an app's own dark
|
|
2
|
+
// look, one of those the shared style guide chose. FoundationProviders'
|
|
3
|
+
// `palette` names the app's default; with `palettePicker` the app shell
|
|
4
|
+
// offers all four and the choice is remembered on this device
|
|
5
|
+
// (localStorage, this store). A new device starts on the default. The
|
|
6
|
+
// palette rides on <html> as data-palette, so popups portaled to <body>
|
|
7
|
+
// wear it too, and styles/theme.css's `.dark[data-palette]` blocks do the
|
|
8
|
+
// rest (components/app/palette-provider.tsx, hooks/use-palette.ts).
|
|
9
|
+
export const PALETTES = ['indigo-cyan', 'indigo-rose', 'graphite-periwinkle', 'violet'];
|
|
10
|
+
/** Each palette's name as a person reads it, in the guide's ranked order. */
|
|
11
|
+
export const PALETTE_LABELS = {
|
|
12
|
+
'indigo-cyan': 'Indigo and Cyan',
|
|
13
|
+
'indigo-rose': 'Indigo and Rose',
|
|
14
|
+
'graphite-periwinkle': 'Graphite and Periwinkle',
|
|
15
|
+
violet: 'Violet',
|
|
16
|
+
};
|
|
17
|
+
/** Where a device's chosen palette is kept. */
|
|
18
|
+
export const PALETTE_STORAGE_KEY = 'ui-foundation:palette';
|
|
19
|
+
export function isPaletteName(value) {
|
|
20
|
+
return typeof value === 'string' && PALETTES.includes(value);
|
|
21
|
+
}
|
|
22
|
+
/** The store under usePalette, taking the storage so its rules are unit tested (tests/palettes.test.ts). */
|
|
23
|
+
export function createPaletteStore(getStorage) {
|
|
24
|
+
// What this page chose, for when storage refuses it.
|
|
25
|
+
let memory = null;
|
|
26
|
+
const listeners = new Set();
|
|
27
|
+
const notify = () => {
|
|
28
|
+
for (const listener of listeners)
|
|
29
|
+
listener();
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
get() {
|
|
33
|
+
if (memory)
|
|
34
|
+
return memory;
|
|
35
|
+
try {
|
|
36
|
+
const stored = getStorage()?.getItem(PALETTE_STORAGE_KEY);
|
|
37
|
+
return isPaletteName(stored) ? stored : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
set(palette) {
|
|
44
|
+
memory = palette;
|
|
45
|
+
try {
|
|
46
|
+
getStorage()?.setItem(PALETTE_STORAGE_KEY, palette);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Storage blocked or full: kept for this page only.
|
|
50
|
+
}
|
|
51
|
+
notify();
|
|
52
|
+
},
|
|
53
|
+
subscribe(listener) {
|
|
54
|
+
listeners.add(listener);
|
|
55
|
+
return () => listeners.delete(listener);
|
|
56
|
+
},
|
|
57
|
+
changedElsewhere() {
|
|
58
|
+
memory = null;
|
|
59
|
+
notify();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export const browserPaletteStore = createPaletteStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
64
|
+
let listeningForOtherTabs = false;
|
|
65
|
+
export function subscribeToBrowserPaletteStore(listener) {
|
|
66
|
+
if (!listeningForOtherTabs && typeof window !== 'undefined') {
|
|
67
|
+
listeningForOtherTabs = true;
|
|
68
|
+
window.addEventListener('storage', (event) => {
|
|
69
|
+
if (event.key === PALETTE_STORAGE_KEY || event.key === null)
|
|
70
|
+
browserPaletteStore.changedElsewhere();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return browserPaletteStore.subscribe(listener);
|
|
74
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, type Page } from '@playwright/test';
|
|
2
2
|
import { type NavEntry } from '../components/app/app-shell-nav.js';
|
|
3
|
+
import { type PaletteName } from '../lib/palettes.js';
|
|
3
4
|
import type { MswOverride } from '../mocks/override.js';
|
|
4
5
|
export type { MswHandle, MswOverride } from '../mocks/override.js';
|
|
5
6
|
export { expect };
|
|
@@ -81,6 +82,13 @@ export type A11ySuiteOptions = {
|
|
|
81
82
|
* the check in one file that passes the whole nav.
|
|
82
83
|
*/
|
|
83
84
|
checkSidebar?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* For an app with a palette (`<FoundationProviders palette>`), which is
|
|
87
|
+
* dark only: the palettes to check instead of light and dark, each saved
|
|
88
|
+
* as the device's choice before the page loads. Pass the app's default on
|
|
89
|
+
* every spec and all of `PALETTES` on one (the shell's).
|
|
90
|
+
*/
|
|
91
|
+
palettes?: readonly PaletteName[];
|
|
84
92
|
};
|
|
85
93
|
/**
|
|
86
94
|
* Every data view on the page has loaded: at least one shows its data (or,
|
|
@@ -103,9 +111,10 @@ export declare function expectDataViewsLoaded(page: Page, { success }?: {
|
|
|
103
111
|
* one file keeps the whole-sidebar check. axe's
|
|
104
112
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
105
113
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
106
|
-
* emulateMedia switches it.
|
|
114
|
+
* emulateMedia switches it. An app with a palette passes `palettes`, and
|
|
115
|
+
* each is checked (dark) in place of light and dark.
|
|
107
116
|
*/
|
|
108
|
-
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav, checkSidebar, }: A11ySuiteOptions): void;
|
|
117
|
+
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav, checkSidebar, palettes, }: A11ySuiteOptions): void;
|
|
109
118
|
export type MockModeBannerSuiteOptions = {
|
|
110
119
|
/** Screens to check, logged-out ones included (e.g. '/login'). */
|
|
111
120
|
routes: readonly string[];
|
|
@@ -137,6 +146,8 @@ export type DataEnvironmentBannerSuiteOptions = {
|
|
|
137
146
|
* (production data, so no banner).
|
|
138
147
|
*/
|
|
139
148
|
dataLabel?: string | null;
|
|
149
|
+
/** For an app with a palette: the palettes to check the banner in, in place of light and dark (as for `defineA11ySuite`). */
|
|
150
|
+
palettes?: readonly PaletteName[];
|
|
140
151
|
};
|
|
141
152
|
/**
|
|
142
153
|
* DataEnvironmentBanner: shown on every route, logged-out ones included,
|
|
@@ -151,4 +162,4 @@ export type DataEnvironmentBannerSuiteOptions = {
|
|
|
151
162
|
* route, or is absent when it's null, which is how a production deploy
|
|
152
163
|
* runs.
|
|
153
164
|
*/
|
|
154
|
-
export declare function defineDataEnvironmentBannerSuite({ routes, mockMode, dataLabel, }: DataEnvironmentBannerSuiteOptions): void;
|
|
165
|
+
export declare function defineDataEnvironmentBannerSuite({ routes, mockMode, dataLabel, palettes, }: DataEnvironmentBannerSuiteOptions): void;
|
package/dist/testing/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import AxeBuilder from '@axe-core/playwright';
|
|
15
15
|
import { expect, test as base } from '@playwright/test';
|
|
16
16
|
import { navPathname, navTargets } from '../components/app/app-shell-nav.js';
|
|
17
|
+
import { PALETTE_STORAGE_KEY } from '../lib/palettes.js';
|
|
17
18
|
export { expect };
|
|
18
19
|
// A page load returns at the load event, but the app isn't up yet: React
|
|
19
20
|
// renders only after MSW's worker.start(), which took up to 2.4 s past
|
|
@@ -174,6 +175,34 @@ async function navRoutes(page, isMobile) {
|
|
|
174
175
|
// The pages a list of routes names, by path: a `to` that opens a list on a
|
|
175
176
|
// saved view ('/widgets?status=active') is the /widgets page.
|
|
176
177
|
const pagePaths = (routes) => [...new Set(routes.map(navPathname))].sort();
|
|
178
|
+
function a11yThemes(palettes) {
|
|
179
|
+
if (palettes?.length)
|
|
180
|
+
return palettes.map((palette) => ({ label: palette, colorScheme: 'dark', palette }));
|
|
181
|
+
return [
|
|
182
|
+
{ label: 'light', colorScheme: 'light' },
|
|
183
|
+
{ label: 'dark', colorScheme: 'dark' },
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
/** Puts the page in a theme: the OS colour scheme, and the palette saved as this device's choice before any script runs. */
|
|
187
|
+
async function applyTheme(page, theme) {
|
|
188
|
+
await page.emulateMedia({ colorScheme: theme.colorScheme });
|
|
189
|
+
if (theme.palette) {
|
|
190
|
+
await page.addInitScript(([key, value]) => {
|
|
191
|
+
try {
|
|
192
|
+
window.localStorage.setItem(key, value);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Storage blocked: the app falls back to its default palette.
|
|
196
|
+
}
|
|
197
|
+
}, [PALETTE_STORAGE_KEY, theme.palette]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** The theme is on the page: its class on <html>, and its palette. */
|
|
201
|
+
async function expectTheme(page, theme) {
|
|
202
|
+
await expect(page.locator('html')).toHaveClass(theme.colorScheme);
|
|
203
|
+
if (theme.palette)
|
|
204
|
+
await expect(page.locator('html')).toHaveAttribute('data-palette', theme.palette);
|
|
205
|
+
}
|
|
177
206
|
// A page's share of the one discovering test's time: load, data, axe.
|
|
178
207
|
const NAV_PAGE_BUDGET_MS = 10_000;
|
|
179
208
|
/**
|
|
@@ -194,9 +223,9 @@ export async function expectDataViewsLoaded(page, { success = true } = {}) {
|
|
|
194
223
|
await expect(page.getByRole('main').first()).toBeVisible();
|
|
195
224
|
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
196
225
|
}
|
|
197
|
-
async function expectNavPageAccessible(page, route,
|
|
226
|
+
async function expectNavPageAccessible(page, route, theme) {
|
|
198
227
|
await page.goto(route);
|
|
199
|
-
await
|
|
228
|
+
await expectTheme(page, theme);
|
|
200
229
|
// Wait for data, not the loading skeleton, so the real UI is checked. A
|
|
201
230
|
// nav page may have no data view, or only an empty one, so none has to
|
|
202
231
|
// show data.
|
|
@@ -212,9 +241,10 @@ async function expectNavPageAccessible(page, route, colorScheme) {
|
|
|
212
241
|
* one file keeps the whole-sidebar check. axe's
|
|
213
242
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
214
243
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
215
|
-
* emulateMedia switches it.
|
|
244
|
+
* emulateMedia switches it. An app with a palette passes `palettes`, and
|
|
245
|
+
* each is checked (dark) in place of light and dark.
|
|
216
246
|
*/
|
|
217
|
-
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes = [], nav, checkSidebar = true, }) {
|
|
247
|
+
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes = [], nav, checkSidebar = true, palettes, }) {
|
|
218
248
|
// Each `to` as given, query string and all, so a page that opens on a
|
|
219
249
|
// saved view is checked on it.
|
|
220
250
|
const listed = nav && navTargets(nav);
|
|
@@ -224,15 +254,15 @@ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes =
|
|
|
224
254
|
expect(pagePaths(rendered), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual(pagePaths(listed));
|
|
225
255
|
});
|
|
226
256
|
}
|
|
227
|
-
for (const
|
|
228
|
-
test.describe(`accessibility (${
|
|
257
|
+
for (const theme of a11yThemes(palettes)) {
|
|
258
|
+
test.describe(`accessibility (${theme.label})`, () => {
|
|
229
259
|
test.beforeEach(async ({ page }) => {
|
|
230
|
-
await page
|
|
260
|
+
await applyTheme(page, theme);
|
|
231
261
|
});
|
|
232
262
|
if (listed) {
|
|
233
263
|
for (const route of listed) {
|
|
234
264
|
test(`nav page ${route} has zero axe violations`, async ({ page }) => {
|
|
235
|
-
await expectNavPageAccessible(page, route,
|
|
265
|
+
await expectNavPageAccessible(page, route, theme);
|
|
236
266
|
});
|
|
237
267
|
}
|
|
238
268
|
}
|
|
@@ -242,7 +272,7 @@ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes =
|
|
|
242
272
|
// One test for every page: its time grows with the app.
|
|
243
273
|
test.setTimeout(testInfo.timeout + routes.length * NAV_PAGE_BUDGET_MS);
|
|
244
274
|
for (const route of routes) {
|
|
245
|
-
await test.step(route, () => expectNavPageAccessible(page, route,
|
|
275
|
+
await test.step(route, () => expectNavPageAccessible(page, route, theme));
|
|
246
276
|
}
|
|
247
277
|
});
|
|
248
278
|
}
|
|
@@ -326,7 +356,7 @@ async function gotoWithDataLabel(page, route) {
|
|
|
326
356
|
* route, or is absent when it's null, which is how a production deploy
|
|
327
357
|
* runs.
|
|
328
358
|
*/
|
|
329
|
-
export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', dataLabel = process.env.DATA_LABEL?.trim() || null, }) {
|
|
359
|
+
export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', dataLabel = process.env.DATA_LABEL?.trim() || null, palettes, }) {
|
|
330
360
|
const banner = (page) => page.getByRole('status').filter({ hasText: "changes here don't reach production" });
|
|
331
361
|
const expectAbsentEverywhere = async (page) => {
|
|
332
362
|
for (const route of routes) {
|
|
@@ -365,13 +395,13 @@ export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.en
|
|
|
365
395
|
const fill = (locator) => locator.evaluate((element) => getComputedStyle(element).backgroundColor);
|
|
366
396
|
expect(await fill(banner(page))).not.toBe(await fill(page.getByRole('status').filter({ hasText: 'Mock data' })));
|
|
367
397
|
});
|
|
368
|
-
for (const
|
|
369
|
-
test(`the data-environment banner has zero axe violations (${
|
|
370
|
-
await page
|
|
398
|
+
for (const theme of a11yThemes(palettes)) {
|
|
399
|
+
test(`the data-environment banner has zero axe violations (${theme.label})`, async ({ page }) => {
|
|
400
|
+
await applyTheme(page, theme);
|
|
371
401
|
await forceLabel(page);
|
|
372
402
|
for (const route of routes) {
|
|
373
403
|
await gotoWithDataLabel(page, route);
|
|
374
|
-
await
|
|
404
|
+
await expectTheme(page, theme);
|
|
375
405
|
await expect(banner(page)).toBeVisible();
|
|
376
406
|
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
377
407
|
await test.step(route, () => expectNoAxeViolations(page));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.63.1",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/styles/index.css
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
composite's own rows and padding tighten under data-density="compact"
|
|
21
21
|
on any ancestor, the way the table's tokens do. `density-compact:py-1`.
|
|
22
22
|
Text never shrinks under it. */
|
|
23
|
+
/* Under a palette only (FoundationProviders' `palette`, data-palette on
|
|
24
|
+
<html>): the shared style guide's glow, which an app without one never
|
|
25
|
+
gets. `palette:hover:shadow-(--primary-glow)`. */
|
|
26
|
+
@custom-variant palette (&:where([data-palette] *));
|
|
27
|
+
|
|
23
28
|
@custom-variant density-compact (&:where([data-density="compact"], [data-density="compact"] *));
|
|
24
29
|
|
|
25
30
|
@theme inline {
|
|
@@ -54,6 +59,7 @@
|
|
|
54
59
|
--color-info-foreground: var(--info-foreground);
|
|
55
60
|
--color-info-text: var(--info-text);
|
|
56
61
|
--color-link: var(--link);
|
|
62
|
+
--color-card-header: var(--card-header);
|
|
57
63
|
--color-highlight-amber: var(--highlight-amber);
|
|
58
64
|
--color-highlight-yellow: var(--highlight-yellow);
|
|
59
65
|
--color-highlight-green: var(--highlight-green);
|
|
@@ -94,6 +100,10 @@
|
|
|
94
100
|
--radius-2xl: calc(var(--radius) * 1.8);
|
|
95
101
|
--radius-3xl: calc(var(--radius) * 2.2);
|
|
96
102
|
--radius-4xl: calc(var(--radius) * 2.6);
|
|
103
|
+
/* Motion (the shared style guide): quick, standard easing, for every
|
|
104
|
+
transition-* utility that doesn't name its own duration. */
|
|
105
|
+
--default-transition-duration: 120ms;
|
|
106
|
+
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
/* Typography roles: one class sets size, line height and weight together,
|
|
@@ -102,11 +112,13 @@
|
|
|
102
112
|
font-size: var(--type-page-title-size);
|
|
103
113
|
line-height: var(--type-page-title-leading);
|
|
104
114
|
font-weight: var(--type-page-title-weight);
|
|
115
|
+
letter-spacing: var(--type-heading-tracking);
|
|
105
116
|
}
|
|
106
117
|
@utility type-section-title {
|
|
107
118
|
font-size: var(--type-section-title-size);
|
|
108
119
|
line-height: var(--type-section-title-leading);
|
|
109
120
|
font-weight: var(--type-section-title-weight);
|
|
121
|
+
letter-spacing: var(--type-heading-tracking);
|
|
110
122
|
}
|
|
111
123
|
@utility type-body {
|
|
112
124
|
font-size: var(--type-body-size);
|
|
@@ -135,11 +147,13 @@
|
|
|
135
147
|
font-size: var(--type-heading-1-size);
|
|
136
148
|
line-height: var(--type-heading-1-leading);
|
|
137
149
|
font-weight: var(--type-heading-1-weight);
|
|
150
|
+
letter-spacing: var(--type-heading-tracking);
|
|
138
151
|
}
|
|
139
152
|
@utility type-heading-2 {
|
|
140
153
|
font-size: var(--type-heading-2-size);
|
|
141
154
|
line-height: var(--type-heading-2-leading);
|
|
142
155
|
font-weight: var(--type-heading-2-weight);
|
|
156
|
+
letter-spacing: var(--type-heading-tracking);
|
|
143
157
|
}
|
|
144
158
|
@utility type-heading-3 {
|
|
145
159
|
font-size: var(--type-heading-3-size);
|
|
@@ -147,6 +161,52 @@
|
|
|
147
161
|
font-weight: var(--type-heading-3-weight);
|
|
148
162
|
}
|
|
149
163
|
|
|
164
|
+
/* A hovered hub tile, widget or card, under a palette: its edge turns the
|
|
165
|
+
accent and a soft halo of it shows (--edge-glow). On an element that
|
|
166
|
+
draws its edge with ring-1 and its shadow with shadow-* (Card does), it
|
|
167
|
+
recolours the ring and adds the halo to the shadow it already has.
|
|
168
|
+
ModuleCard, WidgetCard and Board's cards carry it; a DataTable card
|
|
169
|
+
layout's cards get it with `*:data-[slot=card]:glow-edge` on the
|
|
170
|
+
item. */
|
|
171
|
+
@utility glow-edge {
|
|
172
|
+
transition-property: box-shadow;
|
|
173
|
+
transition-duration: var(--default-transition-duration);
|
|
174
|
+
transition-timing-function: var(--default-transition-timing-function);
|
|
175
|
+
&:where([data-palette] *):hover {
|
|
176
|
+
--tw-ring-color: color-mix(in srgb, var(--primary) 75%, var(--border));
|
|
177
|
+
--tw-shadow: var(--card-shadow, 0 0 #0000), var(--edge-glow);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* A primary button's shine under a palette: a light band sweeping across
|
|
182
|
+
on hover (650ms), once. Not under reduced motion, and never on another
|
|
183
|
+
variant. In components, so a button's own position utility still wins. */
|
|
184
|
+
@layer components {
|
|
185
|
+
[data-palette] [data-slot="button"][data-variant="default"] {
|
|
186
|
+
position: relative;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
}
|
|
189
|
+
[data-palette] [data-slot="button"][data-variant="default"]::after {
|
|
190
|
+
content: "";
|
|
191
|
+
position: absolute;
|
|
192
|
+
inset: 0;
|
|
193
|
+
pointer-events: none;
|
|
194
|
+
background: linear-gradient(105deg, transparent 35%, color-mix(in srgb, var(--white) 40%, transparent) 50%, transparent 65%);
|
|
195
|
+
transform: translateX(-130%);
|
|
196
|
+
}
|
|
197
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
198
|
+
[data-palette] [data-slot="button"][data-variant="default"]:hover::after {
|
|
199
|
+
animation: button-shine 650ms ease-out;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@keyframes button-shine {
|
|
205
|
+
to {
|
|
206
|
+
transform: translateX(130%);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
150
210
|
@layer base {
|
|
151
211
|
* {
|
|
152
212
|
@apply border-border outline-ring/50;
|