@nqmcreative/ui 0.4.1 → 0.4.3
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 +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/tones.d.ts +10 -0
- package/dist/core/tones.js +19 -0
- package/dist/core/trigger.d.ts +8 -0
- package/dist/core/trigger.js +11 -0
- package/dist/styles/matte/Alert.svelte +1 -1
- package/dist/styles/matte/Avatar.svelte +16 -3
- package/dist/styles/matte/Button.svelte +9 -2
- package/dist/styles/matte/Checkbox.svelte +49 -30
- package/dist/styles/matte/CheckboxGroup.svelte +15 -4
- package/dist/styles/matte/Combobox.svelte +12 -3
- package/dist/styles/matte/CommandPalette.svelte +1 -1
- package/dist/styles/matte/ConfirmDialog.svelte +10 -2
- package/dist/styles/matte/ContextMenu.svelte +1 -1
- package/dist/styles/matte/DatePicker.svelte +10 -3
- package/dist/styles/matte/Divider.svelte +1 -1
- package/dist/styles/matte/Drawer.svelte +10 -2
- package/dist/styles/matte/Dropdown.svelte +1 -1
- package/dist/styles/matte/Field.svelte +59 -3
- package/dist/styles/matte/Footer.svelte +14 -3
- package/dist/styles/matte/Input.svelte +9 -2
- package/dist/styles/matte/Label.svelte +3 -1
- package/dist/styles/matte/Link.svelte +13 -2
- package/dist/styles/matte/MenuItem.svelte +11 -4
- package/dist/styles/matte/Modal.svelte +11 -3
- package/dist/styles/matte/MultiSelect.svelte +1 -1
- package/dist/styles/matte/Navbar.svelte +2 -2
- package/dist/styles/matte/Popover.svelte +1 -1
- package/dist/styles/matte/Radio.svelte +41 -22
- package/dist/styles/matte/RadioGroup.svelte +15 -4
- package/dist/styles/matte/Select.svelte +4 -2
- package/dist/styles/matte/Skeleton.svelte +2 -1
- package/dist/styles/matte/Stat.svelte +27 -3
- package/dist/styles/matte/Switch.svelte +6 -1
- package/dist/styles/matte/Tabs.svelte +1 -1
- package/dist/styles/matte/Textarea.svelte +4 -2
- package/dist/styles/matte/Toaster.svelte +21 -3
- package/dist/styles/matte/Tooltip.svelte +8 -1
- package/dist/styles/matte/index.d.ts +1 -1
- package/dist/styles/matte/index.js +1 -1
- package/dist/styles/matte/theme.css +64 -1
- package/dist/styles/paper/AccordionItem.svelte +38 -11
- package/dist/styles/paper/AccordionItem.svelte.d.ts +1 -1
- package/dist/styles/paper/Alert.svelte +2 -2
- package/dist/styles/paper/Avatar.svelte +14 -3
- package/dist/styles/paper/Button.svelte +9 -2
- package/dist/styles/paper/Card.svelte +1 -1
- package/dist/styles/paper/Checkbox.svelte +55 -36
- package/dist/styles/paper/CheckboxGroup.svelte +15 -4
- package/dist/styles/paper/Combobox.svelte +12 -3
- package/dist/styles/paper/CommandPalette.svelte +1 -1
- package/dist/styles/paper/ConfirmDialog.svelte +8 -2
- package/dist/styles/paper/ContextMenu.svelte +1 -1
- package/dist/styles/paper/DatePicker.svelte +10 -3
- package/dist/styles/paper/Divider.svelte +1 -1
- package/dist/styles/paper/Drawer.svelte +8 -2
- package/dist/styles/paper/Dropdown.svelte +1 -1
- package/dist/styles/paper/Field.svelte +59 -3
- package/dist/styles/paper/Footer.svelte +14 -3
- package/dist/styles/paper/Input.svelte +9 -2
- package/dist/styles/paper/Label.svelte +3 -1
- package/dist/styles/paper/Link.svelte +13 -2
- package/dist/styles/paper/MenuItem.svelte +11 -4
- package/dist/styles/paper/Modal.svelte +9 -3
- package/dist/styles/paper/MultiSelect.svelte +1 -1
- package/dist/styles/paper/Navbar.svelte +2 -2
- package/dist/styles/paper/Popover.svelte +1 -1
- package/dist/styles/paper/Radio.svelte +41 -22
- package/dist/styles/paper/RadioGroup.svelte +15 -4
- package/dist/styles/paper/SegmentedControl.svelte +2 -2
- package/dist/styles/paper/Select.svelte +4 -2
- package/dist/styles/paper/Skeleton.svelte +2 -1
- package/dist/styles/paper/Stat.svelte +24 -2
- package/dist/styles/paper/Switch.svelte +6 -1
- package/dist/styles/paper/Tabs.svelte +1 -1
- package/dist/styles/paper/Textarea.svelte +4 -2
- package/dist/styles/paper/Toaster.svelte +19 -3
- package/dist/styles/paper/Tooltip.svelte +8 -1
- package/dist/styles/paper/index.d.ts +1 -1
- package/dist/styles/paper/index.js +1 -1
- package/dist/styles/paper/theme.css +85 -13
- package/dist/styles/sprout/Alert.svelte +3 -3
- package/dist/styles/sprout/Avatar.svelte +14 -3
- package/dist/styles/sprout/Breadcrumb.svelte +1 -1
- package/dist/styles/sprout/Button.svelte +8 -4
- package/dist/styles/sprout/Calendar.svelte +1 -1
- package/dist/styles/sprout/Card.svelte +2 -2
- package/dist/styles/sprout/Checkbox.svelte +55 -36
- package/dist/styles/sprout/CheckboxGroup.svelte +15 -4
- package/dist/styles/sprout/ColorInput.svelte +1 -1
- package/dist/styles/sprout/Combobox.svelte +12 -3
- package/dist/styles/sprout/ConfirmDialog.svelte +8 -2
- package/dist/styles/sprout/DatePicker.svelte +11 -4
- package/dist/styles/sprout/Divider.svelte +1 -1
- package/dist/styles/sprout/Drawer.svelte +8 -2
- package/dist/styles/sprout/Dropzone.svelte +1 -1
- package/dist/styles/sprout/Field.svelte +59 -3
- package/dist/styles/sprout/FileInput.svelte +3 -3
- package/dist/styles/sprout/Footer.svelte +14 -3
- package/dist/styles/sprout/Input.svelte +9 -2
- package/dist/styles/sprout/Label.svelte +3 -1
- package/dist/styles/sprout/Link.svelte +13 -2
- package/dist/styles/sprout/LogoCloud.svelte +1 -1
- package/dist/styles/sprout/MenuItem.svelte +11 -4
- package/dist/styles/sprout/Modal.svelte +8 -2
- package/dist/styles/sprout/MultiSelect.svelte +3 -3
- package/dist/styles/sprout/Navbar.svelte +2 -2
- package/dist/styles/sprout/Radio.svelte +41 -22
- package/dist/styles/sprout/RadioGroup.svelte +15 -4
- package/dist/styles/sprout/SearchInput.svelte +1 -1
- package/dist/styles/sprout/SegmentedControl.svelte +2 -2
- package/dist/styles/sprout/Select.svelte +4 -2
- package/dist/styles/sprout/Sidebar.svelte +2 -1
- package/dist/styles/sprout/Skeleton.svelte +2 -1
- package/dist/styles/sprout/Stat.svelte +24 -2
- package/dist/styles/sprout/Switch.svelte +6 -1
- package/dist/styles/sprout/Table.svelte +1 -1
- package/dist/styles/sprout/Tabs.svelte +1 -1
- package/dist/styles/sprout/TagsInput.svelte +1 -1
- package/dist/styles/sprout/Textarea.svelte +4 -2
- package/dist/styles/sprout/TimeInput.svelte +1 -1
- package/dist/styles/sprout/Toaster.svelte +22 -6
- package/dist/styles/sprout/Tooltip.svelte +8 -1
- package/dist/styles/sprout/index.d.ts +2 -2
- package/dist/styles/sprout/index.js +2 -2
- package/dist/styles/sprout/lift.d.ts +14 -0
- package/dist/styles/sprout/lift.js +23 -0
- package/dist/styles/sprout/theme.css +68 -24
- package/package.json +1 -1
- package/registry.json +4 -2
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ look. Eight tones, light and dark, no runtime dependencies.
|
|
|
8
8
|
| -------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
9
9
|
| `matte` | `@nqmcreative/ui/matte` | Flat and sharp-cornered — no radius, no shadow, no gloss. Hairline rules and mono micro-labels do the work borders usually do. |
|
|
10
10
|
| `paper` | `@nqmcreative/ui/paper` | The neutral one. Start here if unsure: soft corners, light shadows, generous spacing, and nothing that fights the content. |
|
|
11
|
-
| `sprout` | `@nqmcreative/ui/sprout` | Warm and fully rounded — leaf green on
|
|
11
|
+
| `sprout` | `@nqmcreative/ui/sprout` | Warm and fully rounded — leaf green on white, pill buttons, and a round display face. For school, community and family sites. |
|
|
12
12
|
|
|
13
13
|
The docs site lives in this repo — every component with a live demo, plus
|
|
14
14
|
theming and contributing guides. `bun run dev` to read it.
|
package/dist/core/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Styles are free to ignore any of it — but there is no `matte` version of a
|
|
9
9
|
* focus trap, so none of them should have to write one.
|
|
10
10
|
*/
|
|
11
|
-
export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText } from './tones.js';
|
|
11
|
+
export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, focusWithinRing, toneFocusWithinRing, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText } from './tones.js';
|
|
12
12
|
export type { Tone } from './tones.js';
|
|
13
13
|
export { anchored } from './actions/anchor.js';
|
|
14
14
|
export type { Placement, AnchorOptions } from './actions/anchor.js';
|
package/dist/core/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* focus trap, so none of them should have to write one.
|
|
10
10
|
*/
|
|
11
11
|
/* --- tones --- */
|
|
12
|
-
export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText } from './tones.js';
|
|
12
|
+
export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, focusWithinRing, toneFocusWithinRing, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText } from './tones.js';
|
|
13
13
|
/* --- actions --- */
|
|
14
14
|
export { anchored } from './actions/anchor.js';
|
|
15
15
|
export { clickOutside, focusTrap, focusable, navigateList, portal } from './actions/dismissable.js';
|
package/dist/core/tones.d.ts
CHANGED
|
@@ -30,6 +30,16 @@ export declare const toneRing: Record<Tone, string>;
|
|
|
30
30
|
export declare const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2";
|
|
31
31
|
/** Border colour applied while the control itself has focus. */
|
|
32
32
|
export declare const toneFocusBorder: Record<Tone, string>;
|
|
33
|
+
/**
|
|
34
|
+
* Focus-ring geometry for a shell that holds the real control — `Input`'s
|
|
35
|
+
* wrapper, an input group. The ring belongs on the box the reader sees, not on
|
|
36
|
+
* the bare `<input>` inside it, so it is keyed off a focused descendant.
|
|
37
|
+
* `:has(:focus-visible)` rather than `:focus-within` so a pointer click into a
|
|
38
|
+
* field does not draw a keyboard affordance.
|
|
39
|
+
*/
|
|
40
|
+
export declare const focusWithinRing = "has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2";
|
|
41
|
+
/** Ring colour for `focusWithinRing`. */
|
|
42
|
+
export declare const toneFocusWithinRing: Record<Tone, string>;
|
|
33
43
|
/** Border colour applied while a descendant has focus (wrapped controls). */
|
|
34
44
|
export declare const toneFocusWithinBorder: Record<Tone, string>;
|
|
35
45
|
/** Tinted surface only (no text colour) — alerts, callouts, table rows. */
|
package/dist/core/tones.js
CHANGED
|
@@ -128,6 +128,25 @@ export const toneFocusBorder = {
|
|
|
128
128
|
danger: 'focus:border-danger',
|
|
129
129
|
neutral: 'focus:border-neutral'
|
|
130
130
|
};
|
|
131
|
+
/**
|
|
132
|
+
* Focus-ring geometry for a shell that holds the real control — `Input`'s
|
|
133
|
+
* wrapper, an input group. The ring belongs on the box the reader sees, not on
|
|
134
|
+
* the bare `<input>` inside it, so it is keyed off a focused descendant.
|
|
135
|
+
* `:has(:focus-visible)` rather than `:focus-within` so a pointer click into a
|
|
136
|
+
* field does not draw a keyboard affordance.
|
|
137
|
+
*/
|
|
138
|
+
export const focusWithinRing = 'has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2';
|
|
139
|
+
/** Ring colour for `focusWithinRing`. */
|
|
140
|
+
export const toneFocusWithinRing = {
|
|
141
|
+
brand: 'has-[:focus-visible]:outline-brand',
|
|
142
|
+
accent: 'has-[:focus-visible]:outline-accent',
|
|
143
|
+
violet: 'has-[:focus-visible]:outline-violet',
|
|
144
|
+
info: 'has-[:focus-visible]:outline-info',
|
|
145
|
+
success: 'has-[:focus-visible]:outline-success',
|
|
146
|
+
warning: 'has-[:focus-visible]:outline-warning',
|
|
147
|
+
danger: 'has-[:focus-visible]:outline-danger',
|
|
148
|
+
neutral: 'has-[:focus-visible]:outline-neutral'
|
|
149
|
+
};
|
|
131
150
|
/** Border colour applied while a descendant has focus (wrapped controls). */
|
|
132
151
|
export const toneFocusWithinBorder = {
|
|
133
152
|
brand: 'focus-within:border-brand',
|
package/dist/core/trigger.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export declare function popupTrigger(node: HTMLElement, options: PopupTriggerOpt
|
|
|
31
31
|
* wrapper, so screen readers announce the state on the thing being pressed.
|
|
32
32
|
*/
|
|
33
33
|
export declare function describeTrigger(wrapper: HTMLElement | null, kind: 'menu' | 'dialog' | 'listbox', open: boolean): void;
|
|
34
|
+
/**
|
|
35
|
+
* Points the real control inside a wrapper at the element that describes it —
|
|
36
|
+
* a tooltip's text. Same reason as `describeTrigger`: the control is whatever
|
|
37
|
+
* the consumer passed in a snippet, so the attribute has to be attached rather
|
|
38
|
+
* than written in the template. Without it a tooltip is decoration: sighted
|
|
39
|
+
* hover users get the hint, keyboard and screen-reader users get nothing.
|
|
40
|
+
*/
|
|
41
|
+
export declare function describedBy(wrapper: HTMLElement | null, id: string): void;
|
|
34
42
|
/** The menu items a roving-focus menu should walk, in DOM order. */
|
|
35
43
|
export declare function menuItems(menu: HTMLElement | null): HTMLElement[];
|
|
36
44
|
/**
|
package/dist/core/trigger.js
CHANGED
|
@@ -71,6 +71,17 @@ export function describeTrigger(wrapper, kind, open) {
|
|
|
71
71
|
control.setAttribute('aria-haspopup', kind);
|
|
72
72
|
control.setAttribute('aria-expanded', String(open));
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Points the real control inside a wrapper at the element that describes it —
|
|
76
|
+
* a tooltip's text. Same reason as `describeTrigger`: the control is whatever
|
|
77
|
+
* the consumer passed in a snippet, so the attribute has to be attached rather
|
|
78
|
+
* than written in the template. Without it a tooltip is decoration: sighted
|
|
79
|
+
* hover users get the hint, keyboard and screen-reader users get nothing.
|
|
80
|
+
*/
|
|
81
|
+
export function describedBy(wrapper, id) {
|
|
82
|
+
const control = wrapper?.querySelector('button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
83
|
+
control?.setAttribute('aria-describedby', id);
|
|
84
|
+
}
|
|
74
85
|
/** The menu items a roving-focus menu should walk, in DOM order. */
|
|
75
86
|
export function menuItems(menu) {
|
|
76
87
|
return menu ? [...menu.querySelectorAll('[role="menuitem"]:not([disabled])')] : [];
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
|
|
70
70
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
|
71
71
|
{#if title}
|
|
72
|
-
<p class="text-[15px] leading-snug font-medium {toneText[tone]}">{title}</p>
|
|
72
|
+
<p class="font-heading text-[15px] leading-snug font-medium {toneText[tone]}">{title}</p>
|
|
73
73
|
{/if}
|
|
74
74
|
{#if children}
|
|
75
75
|
<div class="text-sm leading-relaxed text-text-secondary">{@render children()}</div>
|
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
xl: 'grid place-items-center *:size-8'
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
// A src that 404s left the reader with the browser's broken-image mark while
|
|
51
|
+
// the initials sat right there unused. Holding the failed URL rather than a
|
|
52
|
+
// flag means a new src is tried again on its own.
|
|
53
|
+
let failed = $state('');
|
|
54
|
+
const broken = $derived(failed !== '' && failed === src);
|
|
55
|
+
|
|
50
56
|
const initials = $derived(
|
|
51
57
|
name
|
|
52
58
|
.trim()
|
|
@@ -60,11 +66,18 @@
|
|
|
60
66
|
|
|
61
67
|
<span
|
|
62
68
|
class="inline-flex shrink-0 items-center justify-center overflow-hidden font-mono font-medium select-none
|
|
63
|
-
{squared ? '' : 'rounded-full'} {sizes[size]} {src
|
|
69
|
+
{squared ? '' : 'rounded-full'} {sizes[size]} {src && !broken
|
|
70
|
+
? 'bg-bg-inset'
|
|
71
|
+
: toneSoft[tone]} {className}"
|
|
64
72
|
{...rest}
|
|
65
73
|
>
|
|
66
|
-
{#if src}
|
|
67
|
-
<img
|
|
74
|
+
{#if src && !broken}
|
|
75
|
+
<img
|
|
76
|
+
{src}
|
|
77
|
+
alt={alt ?? name}
|
|
78
|
+
onerror={() => (failed = src ?? '')}
|
|
79
|
+
class="size-full object-cover"
|
|
80
|
+
/>
|
|
68
81
|
{:else if fallback}
|
|
69
82
|
<span class={glyphs[size]}>{@render fallback()}</span>
|
|
70
83
|
{:else}
|
|
@@ -63,7 +63,10 @@
|
|
|
63
63
|
}: Props = $props();
|
|
64
64
|
|
|
65
65
|
const base =
|
|
66
|
-
|
|
66
|
+
// The press: matte can't sink or scale, so a click reads as the whole
|
|
67
|
+
// button dimming a notch (brightness, not a shadow or a transform).
|
|
68
|
+
// Snaps in at 75ms, settles back over the base 150ms on release.
|
|
69
|
+
'inline-flex items-center justify-center gap-2 border font-sans font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,gap,filter] duration-150 ease-brand-out active:brightness-90 active:duration-75 disabled:opacity-50 disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:pointer-events-none';
|
|
67
70
|
|
|
68
71
|
const sizes: Record<ButtonSize, string> = {
|
|
69
72
|
sm: 'h-8 px-3.5 text-[13px]',
|
|
@@ -105,6 +108,10 @@
|
|
|
105
108
|
const iconClass = $derived(size === 'sm' ? iconSm : iconMd);
|
|
106
109
|
|
|
107
110
|
async function handleClick(event: MouseEvent) {
|
|
111
|
+
if (inert) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
108
115
|
const result = onclick?.(event);
|
|
109
116
|
// Anything else is a plain handler: nothing to wait for, nothing to lock.
|
|
110
117
|
if (!(result instanceof Promise)) return;
|
|
@@ -123,7 +130,7 @@
|
|
|
123
130
|
disabled={href ? undefined : inert}
|
|
124
131
|
aria-disabled={href && inert ? 'true' : undefined}
|
|
125
132
|
aria-busy={busy ? 'true' : undefined}
|
|
126
|
-
|
|
133
|
+
tabindex={href && inert ? -1 : undefined}
|
|
127
134
|
class="{base} {focusRing} {toneRing[tone]} {variantClass} {isLink
|
|
128
135
|
? ''
|
|
129
136
|
: iconOnly
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
...rest
|
|
26
26
|
}: Props = $props();
|
|
27
27
|
|
|
28
|
+
const uid = $props.id();
|
|
29
|
+
|
|
28
30
|
let el: HTMLInputElement | null = $state(null);
|
|
29
31
|
|
|
30
32
|
$effect(() => {
|
|
@@ -32,38 +34,55 @@
|
|
|
32
34
|
});
|
|
33
35
|
</script>
|
|
34
36
|
|
|
35
|
-
<
|
|
36
|
-
class="
|
|
37
|
-
|
|
37
|
+
<div
|
|
38
|
+
class="inline-block font-sans text-[15px] {disabled
|
|
39
|
+
? 'pointer-events-none opacity-50'
|
|
40
|
+
: ''} {className}"
|
|
38
41
|
>
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
<label class="group inline-flex items-start gap-3 {disabled ? '' : 'cursor-pointer'}">
|
|
43
|
+
<input
|
|
44
|
+
bind:this={el}
|
|
45
|
+
type="checkbox"
|
|
46
|
+
bind:checked
|
|
47
|
+
{disabled}
|
|
48
|
+
aria-describedby={description ? `${uid}-description` : undefined}
|
|
49
|
+
class="peer sr-only"
|
|
50
|
+
{...rest}
|
|
51
|
+
/>
|
|
52
|
+
<span
|
|
53
|
+
class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center border border-hairline-strong bg-bg transition-colors duration-150 ease-brand-out
|
|
42
54
|
{tonePeerChecked[tone]} {peerFocusRing} {tonePeerFocus[tone]}"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{/if}
|
|
56
|
-
</span>
|
|
57
|
-
{#if label || description || children}
|
|
58
|
-
<span class="flex flex-col gap-0.5">
|
|
59
|
-
{#if children}
|
|
60
|
-
{@render children()}
|
|
61
|
-
{:else if label}
|
|
62
|
-
<span class="leading-snug text-text">{label}</span>
|
|
63
|
-
{/if}
|
|
64
|
-
{#if description}
|
|
65
|
-
<span class="text-sm leading-snug text-text-muted">{description}</span>
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
>
|
|
57
|
+
{#if indeterminate}
|
|
58
|
+
<span class="h-px w-2.5 bg-text-muted"></span>
|
|
59
|
+
{:else}
|
|
60
|
+
<svg
|
|
61
|
+
class="size-3 text-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
|
|
62
|
+
viewBox="0 0 12 12"
|
|
63
|
+
fill="none"
|
|
64
|
+
>
|
|
65
|
+
<path d="m2 6 2.6 2.6L10 3.2" stroke="currentColor" stroke-width="1.8" />
|
|
66
|
+
</svg>
|
|
66
67
|
{/if}
|
|
67
68
|
</span>
|
|
69
|
+
{#if label || children}
|
|
70
|
+
<span class="flex flex-col gap-0.5">
|
|
71
|
+
{#if children}
|
|
72
|
+
{@render children()}
|
|
73
|
+
{:else if label}
|
|
74
|
+
<span class="leading-snug text-text">{label}</span>
|
|
75
|
+
{/if}
|
|
76
|
+
</span>
|
|
77
|
+
{/if}
|
|
78
|
+
</label>
|
|
79
|
+
{#if description}
|
|
80
|
+
<!-- ponytail: the indent tracks the 18px box plus the label's gap; move one, move both. -->
|
|
81
|
+
<span
|
|
82
|
+
id="{uid}-description"
|
|
83
|
+
class="mt-0.5 block pl-[30px] text-sm leading-snug text-text-muted"
|
|
84
|
+
>
|
|
85
|
+
{description}
|
|
86
|
+
</span>
|
|
68
87
|
{/if}
|
|
69
|
-
</
|
|
88
|
+
</div>
|
|
@@ -50,6 +50,11 @@
|
|
|
50
50
|
...rest
|
|
51
51
|
}: Props = $props();
|
|
52
52
|
|
|
53
|
+
const uid = $props.id();
|
|
54
|
+
// The group's own hint and error sit outside every control in it, so the
|
|
55
|
+
// fieldset is what has to point at them.
|
|
56
|
+
const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
|
|
57
|
+
|
|
53
58
|
const full = $derived(max > 0 && value.length >= max);
|
|
54
59
|
|
|
55
60
|
function toggle(option: CheckboxOption) {
|
|
@@ -60,11 +65,17 @@
|
|
|
60
65
|
}
|
|
61
66
|
</script>
|
|
62
67
|
|
|
63
|
-
<fieldset
|
|
68
|
+
<fieldset
|
|
69
|
+
aria-describedby={messageId}
|
|
70
|
+
class="flex flex-col gap-3 border-0 p-0 {className}"
|
|
71
|
+
{...rest}
|
|
72
|
+
>
|
|
64
73
|
{#if legend}
|
|
65
74
|
<legend class="mb-1 font-sans text-sm font-medium text-text">
|
|
66
75
|
{legend}
|
|
67
|
-
{#if required}<span class="text-danger" aria-hidden="true">*</span
|
|
76
|
+
{#if required}<span class="text-danger" aria-hidden="true">*</span><span class="sr-only"
|
|
77
|
+
>(required)</span
|
|
78
|
+
>{/if}
|
|
68
79
|
</legend>
|
|
69
80
|
{/if}
|
|
70
81
|
|
|
@@ -108,8 +119,8 @@
|
|
|
108
119
|
</div>
|
|
109
120
|
|
|
110
121
|
{#if error}
|
|
111
|
-
<p class="font-sans text-sm text-danger">{error}</p>
|
|
122
|
+
<p id="{uid}-error" role="alert" class="font-sans text-sm text-danger">{error}</p>
|
|
112
123
|
{:else if hint}
|
|
113
|
-
<p class="font-sans text-sm text-text-muted">{hint}</p>
|
|
124
|
+
<p id="{uid}-hint" class="font-sans text-sm text-text-muted">{hint}</p>
|
|
114
125
|
{/if}
|
|
115
126
|
</fieldset>
|
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
import { anchored } from '../../core/actions/anchor.js';
|
|
4
4
|
import { clickOutside, portal } from '../../core/actions/dismissable.js';
|
|
5
5
|
import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
focusWithinRing,
|
|
8
|
+
toneFocusWithinBorder,
|
|
9
|
+
toneFocusWithinRing,
|
|
10
|
+
toneSoft,
|
|
11
|
+
toneText,
|
|
12
|
+
type Tone
|
|
13
|
+
} from '../../core/tones.js';
|
|
7
14
|
import { iconMd } from './icon.js';
|
|
8
15
|
|
|
9
16
|
export interface ComboboxOption {
|
|
@@ -143,7 +150,9 @@
|
|
|
143
150
|
|
|
144
151
|
<div
|
|
145
152
|
bind:this={wrapper}
|
|
146
|
-
class="relative inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-3.5 transition-colors duration-150 ease-brand-out
|
|
153
|
+
class="relative inline-flex w-full items-center gap-2 border bg-bg pr-2 pl-3.5 transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
|
|
154
|
+
tone
|
|
155
|
+
]}
|
|
147
156
|
{invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
|
|
148
157
|
{disabled ? 'pointer-events-none opacity-50' : ''} {className}"
|
|
149
158
|
>
|
|
@@ -205,7 +214,7 @@
|
|
|
205
214
|
use:clickOutside={{ onoutside: () => (open = false), ignore: [wrapper] }}
|
|
206
215
|
id="{id ?? 'combobox'}-listbox"
|
|
207
216
|
role="listbox"
|
|
208
|
-
class="z-50 max-h-64 overflow-y-auto border border-hairline bg-bg py-1 font-sans"
|
|
217
|
+
class="z-50 max-h-64 animate-reveal overflow-y-auto border border-hairline bg-bg py-1 font-sans"
|
|
209
218
|
>
|
|
210
219
|
{#if flat.length === 0}
|
|
211
220
|
<p class="px-3 py-6 text-center text-sm text-text-muted">
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
onclick={(event) => {
|
|
161
161
|
if (event.target === dialog) open = false;
|
|
162
162
|
}}
|
|
163
|
-
class="mx-auto mt-[12vh] mb-auto w-[min(36rem,calc(100vw-2rem))] max-w-none border border-hairline bg-bg p-0 text-text backdrop:bg-text/40 {className}"
|
|
163
|
+
class="mx-auto mt-[12vh] mb-auto w-[min(36rem,calc(100vw-2rem))] max-w-none animate-enter border border-hairline bg-bg p-0 text-text backdrop:bg-text/40 {className}"
|
|
164
164
|
>
|
|
165
165
|
<div use:focusTrap={{ autofocus: false }} onkeydown={onKeydown} role="none" class="flex flex-col">
|
|
166
166
|
<div class="flex items-center gap-3 border-b border-hairline px-3.5">
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
children
|
|
37
37
|
}: Props = $props();
|
|
38
38
|
|
|
39
|
+
const uid = $props.id();
|
|
40
|
+
|
|
39
41
|
async function confirm() {
|
|
40
42
|
try {
|
|
41
43
|
loading = true;
|
|
@@ -55,6 +57,8 @@
|
|
|
55
57
|
|
|
56
58
|
<Modal
|
|
57
59
|
bind:open
|
|
60
|
+
aria-labelledby="{uid}-title"
|
|
61
|
+
aria-describedby={description ? `${uid}-description` : undefined}
|
|
58
62
|
size="sm"
|
|
59
63
|
dismissible={!loading}
|
|
60
64
|
showClose={false}
|
|
@@ -73,9 +77,13 @@
|
|
|
73
77
|
{/if}
|
|
74
78
|
</span>
|
|
75
79
|
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
76
|
-
<h2 class="font-heading text-lg font-medium tracking-tight text-text">
|
|
80
|
+
<h2 id="{uid}-title" class="font-heading text-lg font-medium tracking-tight text-text">
|
|
81
|
+
{title}
|
|
82
|
+
</h2>
|
|
77
83
|
{#if description}
|
|
78
|
-
<p class="text-sm leading-relaxed text-text-secondary">
|
|
84
|
+
<p id="{uid}-description" class="text-sm leading-relaxed text-text-secondary">
|
|
85
|
+
{description}
|
|
86
|
+
</p>
|
|
79
87
|
{/if}
|
|
80
88
|
{#if children}
|
|
81
89
|
{@render children()}
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
role="menu"
|
|
79
79
|
aria-label={label ?? 'Menu'}
|
|
80
80
|
tabindex="-1"
|
|
81
|
-
class="z-50 flex max-h-[min(24rem,80vh)] min-w-44 flex-col overflow-y-auto border border-hairline bg-bg py-1 font-sans {menuClass}"
|
|
81
|
+
class="z-50 flex max-h-[min(24rem,80vh)] min-w-44 animate-reveal flex-col overflow-y-auto border border-hairline bg-bg py-1 font-sans {menuClass}"
|
|
82
82
|
>
|
|
83
83
|
{@render menu()}
|
|
84
84
|
</div>
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
import { clickOutside, portal } from '../../core/actions/dismissable.js';
|
|
4
4
|
import { dateHint } from '../../core/calendar.js';
|
|
5
5
|
import { formatISO, parseFormatted, type DateFormat } from '../../core/date.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
focusWithinRing,
|
|
8
|
+
toneFocusWithinBorder,
|
|
9
|
+
toneFocusWithinRing,
|
|
10
|
+
type Tone
|
|
11
|
+
} from '../../core/tones.js';
|
|
7
12
|
import Calendar from './Calendar.svelte';
|
|
8
13
|
|
|
9
14
|
interface Props {
|
|
@@ -94,7 +99,9 @@
|
|
|
94
99
|
|
|
95
100
|
<div
|
|
96
101
|
bind:this={wrapper}
|
|
97
|
-
class="inline-flex w-full items-center gap-1 border bg-bg pr-1 pl-3.5 transition-colors duration-150 ease-brand-out
|
|
102
|
+
class="inline-flex w-full items-center gap-1 border bg-bg pr-1 pl-3.5 transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
|
|
103
|
+
tone
|
|
104
|
+
]}
|
|
98
105
|
{invalid ? 'border-danger' : `border-hairline ${toneFocusWithinBorder[tone]}`}
|
|
99
106
|
{disabled ? 'pointer-events-none opacity-50' : ''} {className}"
|
|
100
107
|
>
|
|
@@ -162,7 +169,7 @@
|
|
|
162
169
|
use:anchored={{ anchor: wrapper, placement: 'bottom-start', offset: 4 }}
|
|
163
170
|
use:clickOutside={{ onoutside: () => (open = false), ignore: [wrapper] }}
|
|
164
171
|
id="{id ?? 'datepicker'}-calendar"
|
|
165
|
-
class="z-50 border border-hairline bg-bg"
|
|
172
|
+
class="z-50 animate-reveal border border-hairline bg-bg"
|
|
166
173
|
>
|
|
167
174
|
<Calendar
|
|
168
175
|
bind:value
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{...rest}
|
|
21
21
|
></div>
|
|
22
22
|
{:else if label}
|
|
23
|
-
<div class="flex items-center gap-4 {className}" {...rest}>
|
|
23
|
+
<div role="separator" class="flex items-center gap-4 {className}" {...rest}>
|
|
24
24
|
<span class="h-px flex-1 bg-hairline"></span>
|
|
25
25
|
<span class="font-mono text-xs tracking-wide text-text-muted uppercase">{label}</span>
|
|
26
26
|
<span class="h-px flex-1 bg-hairline"></span>
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
...rest
|
|
39
39
|
}: Props = $props();
|
|
40
40
|
|
|
41
|
+
const uid = $props.id();
|
|
42
|
+
|
|
41
43
|
let dialog: HTMLDialogElement | null = $state(null);
|
|
42
44
|
|
|
43
45
|
const horizontal = $derived(side === 'left' || side === 'right');
|
|
@@ -80,6 +82,8 @@
|
|
|
80
82
|
|
|
81
83
|
<dialog
|
|
82
84
|
bind:this={dialog}
|
|
85
|
+
aria-labelledby={title ? `${uid}-title` : undefined}
|
|
86
|
+
aria-describedby={description ? `${uid}-description` : undefined}
|
|
83
87
|
onclose={close}
|
|
84
88
|
onclick={(event) => {
|
|
85
89
|
if (dismissible && isBackdropClick(event, dialog)) close();
|
|
@@ -97,10 +101,14 @@
|
|
|
97
101
|
{#if icon}<span class={iconLg}>{@render icon()}</span>{/if}
|
|
98
102
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
|
99
103
|
{#if title}
|
|
100
|
-
<h2 class="font-heading text-lg font-medium tracking-tight">
|
|
104
|
+
<h2 id="{uid}-title" class="font-heading text-lg font-medium tracking-tight">
|
|
105
|
+
{title}
|
|
106
|
+
</h2>
|
|
101
107
|
{/if}
|
|
102
108
|
{#if description}
|
|
103
|
-
<p class="font-sans text-sm text-text-secondary">
|
|
109
|
+
<p id="{uid}-description" class="font-sans text-sm text-text-secondary">
|
|
110
|
+
{description}
|
|
111
|
+
</p>
|
|
104
112
|
{/if}
|
|
105
113
|
</div>
|
|
106
114
|
{#if dismissible}
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
role="menu"
|
|
85
85
|
aria-label={label ?? 'Menu'}
|
|
86
86
|
tabindex="-1"
|
|
87
|
-
class="z-50 flex max-h-[min(24rem,80vh)] min-w-44 flex-col overflow-y-auto border border-hairline bg-bg py-1 font-sans {className}"
|
|
87
|
+
class="z-50 flex max-h-[min(24rem,80vh)] min-w-44 animate-reveal flex-col overflow-y-auto border border-hairline bg-bg py-1 font-sans {className}"
|
|
88
88
|
>
|
|
89
89
|
{@render children()}
|
|
90
90
|
</div>
|
|
@@ -24,9 +24,65 @@
|
|
|
24
24
|
children,
|
|
25
25
|
...rest
|
|
26
26
|
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
const uid = $props.id();
|
|
29
|
+
const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
|
|
30
|
+
|
|
31
|
+
interface Described {
|
|
32
|
+
for?: string;
|
|
33
|
+
messageId?: string;
|
|
34
|
+
required: boolean;
|
|
35
|
+
invalid: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A hint nobody can hear is not a hint. `aria-describedby` has to sit on the
|
|
40
|
+
* control, and the control arrives through `children` — out of reach of this
|
|
41
|
+
* template — so the field finds it once the DOM exists and keeps the three
|
|
42
|
+
* attributes in step itself. It touches only what it set: an `aria-invalid`
|
|
43
|
+
* the control already carries from its own `invalid` prop stays that
|
|
44
|
+
* control's business.
|
|
45
|
+
*/
|
|
46
|
+
function describes(node: HTMLElement, state: Described) {
|
|
47
|
+
let ourId: string | undefined;
|
|
48
|
+
let ownsInvalid = false;
|
|
49
|
+
|
|
50
|
+
const apply = (next: Described) => {
|
|
51
|
+
const control =
|
|
52
|
+
(next.for ? node.querySelector<HTMLElement>(`#${CSS.escape(next.for)}`) : null) ??
|
|
53
|
+
node.querySelector<HTMLElement>('input, select, textarea');
|
|
54
|
+
if (!control) return;
|
|
55
|
+
|
|
56
|
+
const ids = (control.getAttribute('aria-describedby') ?? '')
|
|
57
|
+
.split(/\s+/)
|
|
58
|
+
.filter((id) => id && id !== ourId);
|
|
59
|
+
if (next.messageId) ids.push(next.messageId);
|
|
60
|
+
if (ids.length) control.setAttribute('aria-describedby', ids.join(' '));
|
|
61
|
+
else control.removeAttribute('aria-describedby');
|
|
62
|
+
ourId = next.messageId;
|
|
63
|
+
|
|
64
|
+
if (next.required) control.setAttribute('aria-required', 'true');
|
|
65
|
+
else control.removeAttribute('aria-required');
|
|
66
|
+
|
|
67
|
+
if (next.invalid) {
|
|
68
|
+
ownsInvalid ||= !control.hasAttribute('aria-invalid');
|
|
69
|
+
control.setAttribute('aria-invalid', 'true');
|
|
70
|
+
} else if (ownsInvalid) {
|
|
71
|
+
control.removeAttribute('aria-invalid');
|
|
72
|
+
ownsInvalid = false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
apply(state);
|
|
77
|
+
return { update: apply };
|
|
78
|
+
}
|
|
27
79
|
</script>
|
|
28
80
|
|
|
29
|
-
<div
|
|
81
|
+
<div
|
|
82
|
+
use:describes={{ for: htmlFor, messageId, required, invalid: Boolean(error) }}
|
|
83
|
+
class="flex w-full flex-col gap-2 {className}"
|
|
84
|
+
{...rest}
|
|
85
|
+
>
|
|
30
86
|
{#if label}
|
|
31
87
|
<label for={htmlFor} class="font-sans text-sm font-medium text-text">
|
|
32
88
|
{label}
|
|
@@ -35,8 +91,8 @@
|
|
|
35
91
|
{/if}
|
|
36
92
|
{@render children()}
|
|
37
93
|
{#if error}
|
|
38
|
-
<p class="font-sans text-sm text-danger">{error}</p>
|
|
94
|
+
<p id="{uid}-error" role="alert" class="font-sans text-sm text-danger">{error}</p>
|
|
39
95
|
{:else if hint}
|
|
40
|
-
<p class="font-sans text-sm text-text-muted">{hint}</p>
|
|
96
|
+
<p id="{uid}-hint" class="font-sans text-sm text-text-muted">{hint}</p>
|
|
41
97
|
{/if}
|
|
42
98
|
</div>
|
|
@@ -61,15 +61,26 @@
|
|
|
61
61
|
<a
|
|
62
62
|
href={item.href}
|
|
63
63
|
target={item.external ? '_blank' : undefined}
|
|
64
|
-
rel={item.external ? 'noreferrer' : undefined}
|
|
64
|
+
rel={item.external ? 'noopener noreferrer' : undefined}
|
|
65
65
|
class="inline-flex items-center gap-2 font-sans text-sm text-text-secondary transition-colors duration-150 ease-brand-out {toneHoverText[
|
|
66
66
|
tone
|
|
67
67
|
]}"
|
|
68
68
|
>
|
|
69
69
|
{#if item.icon}<span class={iconSm}>{@render item.icon()}</span
|
|
70
70
|
>{/if}{item.label}
|
|
71
|
-
{#if item.external}<
|
|
72
|
-
|
|
71
|
+
{#if item.external}<svg
|
|
72
|
+
class="inline-block size-[0.85em] align-[-0.05em]"
|
|
73
|
+
viewBox="0 0 14 14"
|
|
74
|
+
fill="none"
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
><path
|
|
77
|
+
d="M5.5 2.5h6v6M11.5 2.5 3.5 10.5"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
stroke-width="1.5"
|
|
80
|
+
stroke-linecap="round"
|
|
81
|
+
stroke-linejoin="round"
|
|
82
|
+
/></svg
|
|
83
|
+
><span class="sr-only">(opens in a new tab)</span>{/if}
|
|
73
84
|
</a>
|
|
74
85
|
</li>
|
|
75
86
|
{/each}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
focusWithinRing,
|
|
6
|
+
toneFocusWithinBorder,
|
|
7
|
+
toneFocusWithinRing,
|
|
8
|
+
type Tone
|
|
9
|
+
} from '../../core/tones.js';
|
|
5
10
|
|
|
6
11
|
export type InputSize = 'sm' | 'md' | 'lg';
|
|
7
12
|
|
|
@@ -37,7 +42,9 @@
|
|
|
37
42
|
</script>
|
|
38
43
|
|
|
39
44
|
<span
|
|
40
|
-
class="inline-flex w-full items-center gap-2 border bg-bg px-3.5 transition-colors duration-150 ease-brand-out
|
|
45
|
+
class="inline-flex w-full items-center gap-2 border bg-bg px-3.5 transition-colors duration-150 ease-brand-out {focusWithinRing} {toneFocusWithinRing[
|
|
46
|
+
tone
|
|
47
|
+
]}
|
|
41
48
|
{sizes[size]}
|
|
42
49
|
{invalid
|
|
43
50
|
? 'border-danger focus-within:border-danger'
|
|
@@ -12,5 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
<label class="font-sans text-sm font-medium text-text {className}" {...rest}>
|
|
14
14
|
{@render children()}
|
|
15
|
-
{#if required}<span class="text-danger" aria-hidden="true">*</span
|
|
15
|
+
{#if required}<span class="text-danger" aria-hidden="true">*</span><span class="sr-only"
|
|
16
|
+
>(required)</span
|
|
17
|
+
>{/if}
|
|
16
18
|
</label>
|