@dxlbnl/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -0
- package/dist/components/cards/Card.stories.svelte +82 -0
- package/dist/components/cards/Card.stories.svelte.d.ts +19 -0
- package/dist/components/cards/Card.svelte +28 -0
- package/dist/components/cards/Card.svelte.d.ts +12 -0
- package/dist/components/cards/NoteCard.stories.svelte +94 -0
- package/dist/components/cards/NoteCard.stories.svelte.d.ts +19 -0
- package/dist/components/cards/NoteCard.svelte +89 -0
- package/dist/components/cards/NoteCard.svelte.d.ts +18 -0
- package/dist/components/cards/ProductCard.stories.svelte +98 -0
- package/dist/components/cards/ProductCard.stories.svelte.d.ts +19 -0
- package/dist/components/cards/ProductCard.svelte +150 -0
- package/dist/components/cards/ProductCard.svelte.d.ts +22 -0
- package/dist/components/cards/ProjectCard.stories.svelte +88 -0
- package/dist/components/cards/ProjectCard.stories.svelte.d.ts +19 -0
- package/dist/components/cards/ProjectCard.svelte +109 -0
- package/dist/components/cards/ProjectCard.svelte.d.ts +20 -0
- package/dist/components/cards/index.d.ts +4 -0
- package/dist/components/cards/index.js +4 -0
- package/dist/components/data/Accordion.stories.svelte +316 -0
- package/dist/components/data/Accordion.stories.svelte.d.ts +19 -0
- package/dist/components/data/Accordion.svelte +23 -0
- package/dist/components/data/Accordion.svelte.d.ts +9 -0
- package/dist/components/data/AccordionItem.svelte +112 -0
- package/dist/components/data/AccordionItem.svelte.d.ts +11 -0
- package/dist/components/data/Table.composition.stories.svelte +67 -0
- package/dist/components/data/Table.composition.stories.svelte.d.ts +19 -0
- package/dist/components/data/Table.stories.svelte +137 -0
- package/dist/components/data/Table.stories.svelte.d.ts +19 -0
- package/dist/components/data/Table.svelte +83 -0
- package/dist/components/data/Table.svelte.d.ts +14 -0
- package/dist/components/data/Tabs.stories.svelte +386 -0
- package/dist/components/data/Tabs.stories.svelte.d.ts +19 -0
- package/dist/components/data/Tabs.svelte +142 -0
- package/dist/components/data/Tabs.svelte.d.ts +19 -0
- package/dist/components/data/index.d.ts +4 -0
- package/dist/components/data/index.js +4 -0
- package/dist/components/feedback/Modal.stories.svelte +192 -0
- package/dist/components/feedback/Modal.stories.svelte.d.ts +4 -0
- package/dist/components/feedback/Modal.svelte +185 -0
- package/dist/components/feedback/Modal.svelte.d.ts +19 -0
- package/dist/components/feedback/Toast.stories.svelte +203 -0
- package/dist/components/feedback/Toast.stories.svelte.d.ts +19 -0
- package/dist/components/feedback/Toast.svelte +109 -0
- package/dist/components/feedback/Toast.svelte.d.ts +15 -0
- package/dist/components/feedback/ToastRegion.stories.svelte +193 -0
- package/dist/components/feedback/ToastRegion.stories.svelte.d.ts +19 -0
- package/dist/components/feedback/ToastRegion.svelte +102 -0
- package/dist/components/feedback/ToastRegion.svelte.d.ts +9 -0
- package/dist/components/feedback/index.d.ts +3 -0
- package/dist/components/feedback/index.js +3 -0
- package/dist/components/forms/Checkbox.stories.svelte +103 -0
- package/dist/components/forms/Checkbox.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Checkbox.svelte +150 -0
- package/dist/components/forms/Checkbox.svelte.d.ts +11 -0
- package/dist/components/forms/Field.stories.svelte +113 -0
- package/dist/components/forms/Field.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Field.svelte +77 -0
- package/dist/components/forms/Field.svelte.d.ts +17 -0
- package/dist/components/forms/Input.stories.svelte +58 -0
- package/dist/components/forms/Input.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Input.svelte +64 -0
- package/dist/components/forms/Input.svelte.d.ts +9 -0
- package/dist/components/forms/InputWrap.composition.stories.svelte +32 -0
- package/dist/components/forms/InputWrap.composition.stories.svelte.d.ts +19 -0
- package/dist/components/forms/InputWrap.stories.svelte +53 -0
- package/dist/components/forms/InputWrap.stories.svelte.d.ts +19 -0
- package/dist/components/forms/InputWrap.svelte +128 -0
- package/dist/components/forms/InputWrap.svelte.d.ts +21 -0
- package/dist/components/forms/Radio.stories.svelte +70 -0
- package/dist/components/forms/Radio.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Radio.svelte +109 -0
- package/dist/components/forms/Radio.svelte.d.ts +9 -0
- package/dist/components/forms/RadioGroup.stories.svelte +115 -0
- package/dist/components/forms/RadioGroup.stories.svelte.d.ts +19 -0
- package/dist/components/forms/RadioGroup.svelte +116 -0
- package/dist/components/forms/RadioGroup.svelte.d.ts +24 -0
- package/dist/components/forms/Select.stories.svelte +168 -0
- package/dist/components/forms/Select.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Select.svelte +262 -0
- package/dist/components/forms/Select.svelte.d.ts +23 -0
- package/dist/components/forms/Switch.stories.svelte +86 -0
- package/dist/components/forms/Switch.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Switch.svelte +113 -0
- package/dist/components/forms/Switch.svelte.d.ts +11 -0
- package/dist/components/forms/Textarea.stories.svelte +40 -0
- package/dist/components/forms/Textarea.stories.svelte.d.ts +19 -0
- package/dist/components/forms/Textarea.svelte +66 -0
- package/dist/components/forms/Textarea.svelte.d.ts +9 -0
- package/dist/components/forms/field-context.d.ts +7 -0
- package/dist/components/forms/field-context.js +1 -0
- package/dist/components/forms/index.d.ts +9 -0
- package/dist/components/forms/index.js +9 -0
- package/dist/components/layout/Container.stories.svelte +67 -0
- package/dist/components/layout/Container.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Container.svelte +52 -0
- package/dist/components/layout/Container.svelte.d.ts +14 -0
- package/dist/components/layout/Grid.stories.svelte +109 -0
- package/dist/components/layout/Grid.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Grid.svelte +54 -0
- package/dist/components/layout/Grid.svelte.d.ts +19 -0
- package/dist/components/layout/Inline.stories.svelte +136 -0
- package/dist/components/layout/Inline.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Inline.svelte +46 -0
- package/dist/components/layout/Inline.svelte.d.ts +19 -0
- package/dist/components/layout/Prose.stories.svelte +423 -0
- package/dist/components/layout/Prose.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Prose.svelte +176 -0
- package/dist/components/layout/Prose.svelte.d.ts +12 -0
- package/dist/components/layout/Rule.stories.svelte +80 -0
- package/dist/components/layout/Rule.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Rule.svelte +33 -0
- package/dist/components/layout/Rule.svelte.d.ts +9 -0
- package/dist/components/layout/Spread.stories.svelte +118 -0
- package/dist/components/layout/Spread.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Spread.svelte +38 -0
- package/dist/components/layout/Spread.svelte.d.ts +16 -0
- package/dist/components/layout/Stack.stories.svelte +90 -0
- package/dist/components/layout/Stack.stories.svelte.d.ts +19 -0
- package/dist/components/layout/Stack.svelte +37 -0
- package/dist/components/layout/Stack.svelte.d.ts +16 -0
- package/dist/components/layout/index.d.ts +7 -0
- package/dist/components/layout/index.js +7 -0
- package/dist/components/navigation/Breadcrumb.stories.svelte +122 -0
- package/dist/components/navigation/Breadcrumb.stories.svelte.d.ts +19 -0
- package/dist/components/navigation/Breadcrumb.svelte +70 -0
- package/dist/components/navigation/Breadcrumb.svelte.d.ts +13 -0
- package/dist/components/navigation/Nav.stories.svelte +323 -0
- package/dist/components/navigation/Nav.stories.svelte.d.ts +19 -0
- package/dist/components/navigation/Nav.svelte +257 -0
- package/dist/components/navigation/Nav.svelte.d.ts +21 -0
- package/dist/components/navigation/index.d.ts +2 -0
- package/dist/components/navigation/index.js +2 -0
- package/dist/components/patterns/ActivityRow.stories.svelte +45 -0
- package/dist/components/patterns/ActivityRow.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/ActivityRow.svelte +69 -0
- package/dist/components/patterns/ActivityRow.svelte.d.ts +16 -0
- package/dist/components/patterns/Alert.stories.svelte +63 -0
- package/dist/components/patterns/Alert.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/Alert.svelte +91 -0
- package/dist/components/patterns/Alert.svelte.d.ts +16 -0
- package/dist/components/patterns/CtaBlock.stories.svelte +62 -0
- package/dist/components/patterns/CtaBlock.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/CtaBlock.svelte +80 -0
- package/dist/components/patterns/CtaBlock.svelte.d.ts +16 -0
- package/dist/components/patterns/KvList.stories.svelte +48 -0
- package/dist/components/patterns/KvList.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/KvList.svelte +65 -0
- package/dist/components/patterns/KvList.svelte.d.ts +15 -0
- package/dist/components/patterns/PageHero.stories.svelte +62 -0
- package/dist/components/patterns/PageHero.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/PageHero.svelte +62 -0
- package/dist/components/patterns/PageHero.svelte.d.ts +14 -0
- package/dist/components/patterns/ProgressBar.stories.svelte +83 -0
- package/dist/components/patterns/ProgressBar.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/ProgressBar.svelte +71 -0
- package/dist/components/patterns/ProgressBar.svelte.d.ts +13 -0
- package/dist/components/patterns/SectionFoot.stories.svelte +37 -0
- package/dist/components/patterns/SectionFoot.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/SectionFoot.svelte +70 -0
- package/dist/components/patterns/SectionFoot.svelte.d.ts +15 -0
- package/dist/components/patterns/SectionHead.stories.svelte +67 -0
- package/dist/components/patterns/SectionHead.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/SectionHead.svelte +54 -0
- package/dist/components/patterns/SectionHead.svelte.d.ts +14 -0
- package/dist/components/patterns/StatCard.stories.svelte +59 -0
- package/dist/components/patterns/StatCard.stories.svelte.d.ts +19 -0
- package/dist/components/patterns/StatCard.svelte +57 -0
- package/dist/components/patterns/StatCard.svelte.d.ts +15 -0
- package/dist/components/patterns/index.d.ts +9 -0
- package/dist/components/patterns/index.js +9 -0
- package/dist/components/primitives/Button.stories.svelte +132 -0
- package/dist/components/primitives/Button.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Button.svelte +142 -0
- package/dist/components/primitives/Button.svelte.d.ts +16 -0
- package/dist/components/primitives/Heading.stories.svelte +137 -0
- package/dist/components/primitives/Heading.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Heading.svelte +107 -0
- package/dist/components/primitives/Heading.svelte.d.ts +23 -0
- package/dist/components/primitives/Led.stories.svelte +63 -0
- package/dist/components/primitives/Led.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Led.svelte +65 -0
- package/dist/components/primitives/Led.svelte.d.ts +11 -0
- package/dist/components/primitives/TagPill.stories.svelte +90 -0
- package/dist/components/primitives/TagPill.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/TagPill.svelte +44 -0
- package/dist/components/primitives/TagPill.svelte.d.ts +9 -0
- package/dist/components/primitives/Text.stories.svelte +252 -0
- package/dist/components/primitives/Text.stories.svelte.d.ts +19 -0
- package/dist/components/primitives/Text.svelte +101 -0
- package/dist/components/primitives/Text.svelte.d.ts +25 -0
- package/dist/components/primitives/index.d.ts +5 -0
- package/dist/components/primitives/index.js +5 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/stores/toast.d.ts +19 -0
- package/dist/stores/toast.js +22 -0
- package/dist/storybook-utils.d.ts +11 -0
- package/dist/storybook-utils.js +29 -0
- package/dist/tokens/ColorSwatch.svelte +73 -0
- package/dist/tokens/ColorSwatch.svelte.d.ts +10 -0
- package/dist/tokens/layout.css +144 -0
- package/dist/tokens/patterns.css +281 -0
- package/dist/tokens/tokens.css +96 -0
- package/dist/tokens/tokens.stories.svelte +107 -0
- package/dist/tokens/tokens.stories.svelte.d.ts +18 -0
- package/dist/tokens/typography.css +159 -0
- package/package.json +62 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
3
|
+
import Radio from './Radio.svelte'
|
|
4
|
+
|
|
5
|
+
interface RadioOption {
|
|
6
|
+
value: string
|
|
7
|
+
label: string
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'onchange'> {
|
|
12
|
+
/** `<legend>` text for the fieldset. */
|
|
13
|
+
legend: string
|
|
14
|
+
/** `name` attribute shared by all radio inputs in the group. */
|
|
15
|
+
name: string
|
|
16
|
+
/** Array of `{ value, label, disabled? }` options. */
|
|
17
|
+
options: RadioOption[]
|
|
18
|
+
/** Currently selected value. */
|
|
19
|
+
value?: string
|
|
20
|
+
/** Disable all radios in the group. @default false */
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
/** Called with the newly selected value string. */
|
|
23
|
+
onchange?: (value: string) => void
|
|
24
|
+
[key: string]: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
legend,
|
|
29
|
+
name,
|
|
30
|
+
options,
|
|
31
|
+
value,
|
|
32
|
+
disabled = false,
|
|
33
|
+
onchange,
|
|
34
|
+
...rest
|
|
35
|
+
}: Props = $props()
|
|
36
|
+
|
|
37
|
+
// Returns the tabindex for each radio: 0 for the selected (or first enabled if none selected), -1 for all others
|
|
38
|
+
function getTabIndex(optionValue: string, index: number): number {
|
|
39
|
+
if (value !== undefined) {
|
|
40
|
+
return optionValue === value ? 0 : -1
|
|
41
|
+
}
|
|
42
|
+
// No selection: first enabled radio gets tabindex=0
|
|
43
|
+
const firstEnabledIndex = options.findIndex(o => !o.disabled && !disabled)
|
|
44
|
+
return index === firstEnabledIndex ? 0 : -1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
48
|
+
const target = event.target as HTMLInputElement
|
|
49
|
+
if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(event.key)) return
|
|
50
|
+
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
|
|
53
|
+
// Find all enabled radio inputs inside the fieldset
|
|
54
|
+
const fieldset = target.closest('fieldset')
|
|
55
|
+
if (!fieldset) return
|
|
56
|
+
const inputs = Array.from(fieldset.querySelectorAll<HTMLInputElement>('input[type="radio"]:not([disabled])'))
|
|
57
|
+
if (inputs.length === 0) return
|
|
58
|
+
|
|
59
|
+
const currentIndex = inputs.indexOf(target)
|
|
60
|
+
if (currentIndex === -1) return
|
|
61
|
+
|
|
62
|
+
let nextIndex: number
|
|
63
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
64
|
+
nextIndex = (currentIndex + 1) % inputs.length
|
|
65
|
+
} else {
|
|
66
|
+
nextIndex = (currentIndex - 1 + inputs.length) % inputs.length
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const nextInput = inputs[nextIndex]
|
|
70
|
+
nextInput.focus()
|
|
71
|
+
|
|
72
|
+
// Find the option value for this input and fire onchange
|
|
73
|
+
const nextValue = nextInput.value
|
|
74
|
+
onchange?.(nextValue)
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<fieldset class="radio-group" class:disabled={disabled} {disabled} onkeydown={handleKeydown} {...rest}>
|
|
79
|
+
<legend class="radio-group-legend">{legend}</legend>
|
|
80
|
+
{#each options as option, i}
|
|
81
|
+
<Radio
|
|
82
|
+
{name}
|
|
83
|
+
value={option.value}
|
|
84
|
+
label={option.label}
|
|
85
|
+
checked={option.value === value}
|
|
86
|
+
disabled={disabled || option.disabled}
|
|
87
|
+
tabindex={getTabIndex(option.value, i)}
|
|
88
|
+
onchange={() => onchange?.(option.value)}
|
|
89
|
+
/>
|
|
90
|
+
{/each}
|
|
91
|
+
</fieldset>
|
|
92
|
+
|
|
93
|
+
<style>
|
|
94
|
+
.radio-group {
|
|
95
|
+
border: none;
|
|
96
|
+
padding: 0;
|
|
97
|
+
margin: 0;
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
gap: 8px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.radio-group-legend {
|
|
104
|
+
font-family: var(--mono);
|
|
105
|
+
font-size: 10px;
|
|
106
|
+
letter-spacing: 0.1em;
|
|
107
|
+
text-transform: uppercase;
|
|
108
|
+
color: var(--ink-faint);
|
|
109
|
+
margin-bottom: 4px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.radio-group.disabled {
|
|
113
|
+
opacity: 0.4;
|
|
114
|
+
cursor: not-allowed;
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
interface RadioOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, 'onchange'> {
|
|
8
|
+
/** `<legend>` text for the fieldset. */
|
|
9
|
+
legend: string;
|
|
10
|
+
/** `name` attribute shared by all radio inputs in the group. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Array of `{ value, label, disabled? }` options. */
|
|
13
|
+
options: RadioOption[];
|
|
14
|
+
/** Currently selected value. */
|
|
15
|
+
value?: string;
|
|
16
|
+
/** Disable all radios in the group. @default false */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Called with the newly selected value string. */
|
|
19
|
+
onchange?: (value: string) => void;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
declare const RadioGroup: import("svelte").Component<Props, {}, "">;
|
|
23
|
+
type RadioGroup = ReturnType<typeof RadioGroup>;
|
|
24
|
+
export default RadioGroup;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import { defineMeta } from "@storybook/addon-svelte-csf";
|
|
3
|
+
import { expect, within } from "storybook/test";
|
|
4
|
+
import Select from "./Select.svelte";
|
|
5
|
+
import { resolveTokenColor } from "../../storybook-utils.js";
|
|
6
|
+
|
|
7
|
+
const OPTIONS = [
|
|
8
|
+
{ value: "osc", label: "Oscillator" },
|
|
9
|
+
{ value: "env", label: "Envelope" },
|
|
10
|
+
{ value: "util", label: "Utility" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const { Story } = defineMeta({
|
|
14
|
+
title: "Forms/Select",
|
|
15
|
+
component: Select,
|
|
16
|
+
tags: ["autodocs"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let _lastOnchange: string | undefined;
|
|
20
|
+
function captureOnchange(v: string) { _lastOnchange = v; }
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<Story name="Default" args={{ options: OPTIONS, placeholder: "SELECT…" }}
|
|
24
|
+
play={async ({ canvasElement }) => {
|
|
25
|
+
const canvas = within(canvasElement);
|
|
26
|
+
const trigger = canvas.getByRole("button");
|
|
27
|
+
await expect(trigger).toBeVisible();
|
|
28
|
+
await expect(trigger).toBeEnabled();
|
|
29
|
+
await expect(trigger.textContent).toContain("SELECT…");
|
|
30
|
+
await expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
31
|
+
await expect(canvas.queryByRole("listbox")).toBeNull();
|
|
32
|
+
}} />
|
|
33
|
+
|
|
34
|
+
<Story name="Open Panel" args={{ options: OPTIONS, placeholder: "SELECT…" }}
|
|
35
|
+
play={async ({ canvasElement, userEvent }) => {
|
|
36
|
+
const canvas = within(canvasElement);
|
|
37
|
+
const trigger = canvas.getByRole("button");
|
|
38
|
+
await userEvent.click(trigger);
|
|
39
|
+
await expect(canvas.getByRole("listbox")).toBeVisible();
|
|
40
|
+
await expect(canvas.getAllByRole("option")).toHaveLength(3);
|
|
41
|
+
await expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
42
|
+
}} />
|
|
43
|
+
|
|
44
|
+
<Story name="With Selection" args={{ options: OPTIONS, value: "env" }}
|
|
45
|
+
play={async ({ canvasElement, userEvent }) => {
|
|
46
|
+
const canvas = within(canvasElement);
|
|
47
|
+
const trigger = canvas.getByRole("button");
|
|
48
|
+
await expect(trigger.textContent).toContain("Envelope");
|
|
49
|
+
await userEvent.click(trigger);
|
|
50
|
+
const options = canvas.getAllByRole("option");
|
|
51
|
+
const envelopeOption = options.find((o) => o.textContent && o.textContent.includes("Envelope"))!;
|
|
52
|
+
await expect(envelopeOption.getAttribute("aria-selected")).toBe("true");
|
|
53
|
+
for (const option of options) {
|
|
54
|
+
if (option !== envelopeOption) {
|
|
55
|
+
await expect(option.getAttribute("aria-selected")).toBe("false");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}} />
|
|
59
|
+
|
|
60
|
+
<Story name="Disabled" args={{ options: OPTIONS, disabled: true }}
|
|
61
|
+
play={async ({ canvasElement }) => {
|
|
62
|
+
const canvas = within(canvasElement);
|
|
63
|
+
const trigger = canvas.getByRole("button");
|
|
64
|
+
// AC-21: disabled button cannot be interacted with; toBeDisabled() proves keyboard/click
|
|
65
|
+
// events have no effect (browser prevents activation on disabled elements)
|
|
66
|
+
await expect(trigger).toBeDisabled();
|
|
67
|
+
await expect(getComputedStyle(trigger).opacity).toBe("0.4");
|
|
68
|
+
await expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
69
|
+
}} />
|
|
70
|
+
|
|
71
|
+
<Story name="Error State" args={{ options: OPTIONS, error: true }}
|
|
72
|
+
play={async ({ canvasElement }) => {
|
|
73
|
+
const canvas = within(canvasElement);
|
|
74
|
+
const trigger = canvas.getByRole("button");
|
|
75
|
+
const dangerColor = resolveTokenColor("--danger");
|
|
76
|
+
await expect(getComputedStyle(trigger).borderColor).toBe(dangerColor);
|
|
77
|
+
}} />
|
|
78
|
+
|
|
79
|
+
<!-- B15: Keyboard Navigation story — exercises the ARIA Listbox keyboard pattern. -->
|
|
80
|
+
<Story name="Keyboard Navigation"
|
|
81
|
+
args={{
|
|
82
|
+
options: [
|
|
83
|
+
{ value: "osc", label: "Oscillator" },
|
|
84
|
+
{ value: "env", label: "Envelope" },
|
|
85
|
+
{ value: "util", label: "Utility" },
|
|
86
|
+
],
|
|
87
|
+
onchange: captureOnchange,
|
|
88
|
+
}}
|
|
89
|
+
play={async ({ canvasElement, userEvent }) => {
|
|
90
|
+
_lastOnchange = undefined;
|
|
91
|
+
const canvas = within(canvasElement);
|
|
92
|
+
const trigger = canvas.getByRole("button");
|
|
93
|
+
|
|
94
|
+
// Step 1: open the panel
|
|
95
|
+
await userEvent.click(trigger);
|
|
96
|
+
await expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
97
|
+
|
|
98
|
+
// AC-6: highlight initialises to first option when no selection
|
|
99
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-0");
|
|
100
|
+
|
|
101
|
+
// AC-7: highlighted option has class "highlighted"
|
|
102
|
+
const options = canvas.getAllByRole("option");
|
|
103
|
+
await expect(options[0]).toHaveClass("highlighted");
|
|
104
|
+
|
|
105
|
+
// AC-12: ArrowUp from index 0 wraps to last (index 2)
|
|
106
|
+
await userEvent.keyboard("{ArrowUp}");
|
|
107
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-2");
|
|
108
|
+
|
|
109
|
+
// Return to index 0 via Home
|
|
110
|
+
await userEvent.keyboard("{Home}");
|
|
111
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-0");
|
|
112
|
+
|
|
113
|
+
// AC-8: ArrowDown moves to index 1
|
|
114
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
115
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-1");
|
|
116
|
+
|
|
117
|
+
// AC-11: ArrowUp moves back to index 0
|
|
118
|
+
await userEvent.keyboard("{ArrowUp}");
|
|
119
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-0");
|
|
120
|
+
|
|
121
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
122
|
+
|
|
123
|
+
// AC-8 cont: ArrowDown to index 2
|
|
124
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
125
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-2");
|
|
126
|
+
|
|
127
|
+
// AC-9: ArrowDown wraps last→first
|
|
128
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
129
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-0");
|
|
130
|
+
|
|
131
|
+
// AC-14: End jumps to last
|
|
132
|
+
await userEvent.keyboard("{End}");
|
|
133
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-2");
|
|
134
|
+
|
|
135
|
+
// AC-13: Home jumps to first
|
|
136
|
+
await userEvent.keyboard("{Home}");
|
|
137
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-0");
|
|
138
|
+
|
|
139
|
+
// Move to index 1 (Envelope)
|
|
140
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
141
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-1");
|
|
142
|
+
|
|
143
|
+
// AC-19: only highlighted option has class "highlighted"
|
|
144
|
+
const optionsNow = canvas.getAllByRole("option");
|
|
145
|
+
await expect(optionsNow[1]).toHaveClass("highlighted");
|
|
146
|
+
await expect(optionsNow[0]).not.toHaveClass("highlighted");
|
|
147
|
+
|
|
148
|
+
// AC-15: Enter calls onchange with the highlighted option's value
|
|
149
|
+
// AC-16, AC-17: panel closes, trigger displays confirmed label
|
|
150
|
+
await userEvent.keyboard("{Enter}");
|
|
151
|
+
await expect(_lastOnchange).toBe("env");
|
|
152
|
+
await expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
153
|
+
await expect(canvas.queryByRole("listbox")).toBeNull();
|
|
154
|
+
await expect(trigger.textContent).toMatch(/ENVELOPE/i);
|
|
155
|
+
|
|
156
|
+
// AC-22: re-open highlights the selected option (index 1)
|
|
157
|
+
await userEvent.click(trigger);
|
|
158
|
+
await expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
159
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBe("select-opt-1");
|
|
160
|
+
|
|
161
|
+
// AC-18: Escape closes without changing selection
|
|
162
|
+
await userEvent.keyboard("{Escape}");
|
|
163
|
+
await expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
164
|
+
await expect(trigger.textContent).toMatch(/ENVELOPE/i);
|
|
165
|
+
|
|
166
|
+
// AC-5: aria-activedescendant absent when panel is closed
|
|
167
|
+
await expect(trigger.getAttribute("aria-activedescendant")).toBeNull();
|
|
168
|
+
}} />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Select from "./Select.svelte";
|
|
2
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
3
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
4
|
+
$$bindings?: Bindings;
|
|
5
|
+
} & Exports;
|
|
6
|
+
(internal: unknown, props: {
|
|
7
|
+
$$events?: Events;
|
|
8
|
+
$$slots?: Slots;
|
|
9
|
+
}): Exports & {
|
|
10
|
+
$set?: any;
|
|
11
|
+
$on?: any;
|
|
12
|
+
};
|
|
13
|
+
z_$$bindings?: Bindings;
|
|
14
|
+
}
|
|
15
|
+
declare const Select: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
16
|
+
[evt: string]: CustomEvent<any>;
|
|
17
|
+
}, {}, {}, string>;
|
|
18
|
+
type Select = InstanceType<typeof Select>;
|
|
19
|
+
export default Select;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import Button from "../primitives/Button.svelte";
|
|
4
|
+
|
|
5
|
+
interface SelectOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "onchange"> {
|
|
11
|
+
/** Array of `{ value, label }` option objects. */
|
|
12
|
+
options: SelectOption[];
|
|
13
|
+
/** Currently selected value. */
|
|
14
|
+
value?: string;
|
|
15
|
+
/** Label shown when no value is selected. @default 'SELECT…' */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
/** Show the error (danger-border) state. @default false */
|
|
18
|
+
error?: boolean;
|
|
19
|
+
/** Disable the select — prevents opening the dropdown. @default false */
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
/** Called with the new value string when the user selects an option. */
|
|
22
|
+
onchange?: (value: string) => void;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
options,
|
|
28
|
+
value = undefined,
|
|
29
|
+
placeholder = "SELECT…",
|
|
30
|
+
error = false,
|
|
31
|
+
disabled = false,
|
|
32
|
+
onchange,
|
|
33
|
+
...rest
|
|
34
|
+
}: Props = $props();
|
|
35
|
+
|
|
36
|
+
let open = $state(false);
|
|
37
|
+
let rootEl: HTMLDivElement | undefined = $state(undefined);
|
|
38
|
+
let highlightedIndex = $state(-1);
|
|
39
|
+
// internal committed value — tracks the last selection so displayLabel
|
|
40
|
+
// reflects the user's choice even when the caller doesn't update the value prop
|
|
41
|
+
let internalValue = $state(value);
|
|
42
|
+
|
|
43
|
+
// keep internalValue in sync when the value prop changes from outside
|
|
44
|
+
$effect(() => {
|
|
45
|
+
internalValue = value;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let displayLabel = $derived(
|
|
49
|
+
options?.find((o) => o.value === internalValue)?.label ?? placeholder,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
function handleTriggerClick() {
|
|
53
|
+
if (disabled) return;
|
|
54
|
+
open = !open;
|
|
55
|
+
if (open) {
|
|
56
|
+
const idx = options?.findIndex((o) => o.value === internalValue) ?? -1;
|
|
57
|
+
highlightedIndex = idx >= 0 ? idx : 0;
|
|
58
|
+
} else {
|
|
59
|
+
highlightedIndex = -1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handleSelect(newValue: string) {
|
|
64
|
+
internalValue = newValue;
|
|
65
|
+
open = false;
|
|
66
|
+
highlightedIndex = -1;
|
|
67
|
+
onchange?.(newValue);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
71
|
+
if (e.key === "Escape") {
|
|
72
|
+
open = false;
|
|
73
|
+
highlightedIndex = -1;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!open) return;
|
|
77
|
+
const count = options.length;
|
|
78
|
+
if (count === 0) return;
|
|
79
|
+
switch (e.key) {
|
|
80
|
+
case "ArrowDown":
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
highlightedIndex = (highlightedIndex + 1) % count;
|
|
83
|
+
break;
|
|
84
|
+
case "ArrowUp":
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
highlightedIndex = (highlightedIndex - 1 + count) % count;
|
|
87
|
+
break;
|
|
88
|
+
case "Home":
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
highlightedIndex = 0;
|
|
91
|
+
break;
|
|
92
|
+
case "End":
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
highlightedIndex = count - 1;
|
|
95
|
+
break;
|
|
96
|
+
case "Enter":
|
|
97
|
+
if (highlightedIndex >= 0) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
handleSelect(options[highlightedIndex].value);
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
$effect(() => {
|
|
106
|
+
if (!open) return;
|
|
107
|
+
const handler = (e: MouseEvent) => {
|
|
108
|
+
if (rootEl && !rootEl.contains(e.target as Node)) {
|
|
109
|
+
open = false;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
document.addEventListener("click", handler);
|
|
113
|
+
return () => document.removeEventListener("click", handler);
|
|
114
|
+
});
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<div
|
|
118
|
+
class="select"
|
|
119
|
+
class:disabled
|
|
120
|
+
bind:this={rootEl}
|
|
121
|
+
onkeydown={handleKeydown}
|
|
122
|
+
role="presentation"
|
|
123
|
+
{...rest}
|
|
124
|
+
>
|
|
125
|
+
<Button
|
|
126
|
+
variant="ghost"
|
|
127
|
+
type="button"
|
|
128
|
+
class={[open && "open", error && "err"].filter(Boolean).join(" ")}
|
|
129
|
+
aria-haspopup="listbox"
|
|
130
|
+
aria-expanded={open}
|
|
131
|
+
aria-disabled={disabled}
|
|
132
|
+
aria-activedescendant={open && highlightedIndex >= 0
|
|
133
|
+
? `select-opt-${highlightedIndex}`
|
|
134
|
+
: undefined}
|
|
135
|
+
{disabled}
|
|
136
|
+
onclick={handleTriggerClick}
|
|
137
|
+
>
|
|
138
|
+
<span class="select-value">{displayLabel}</span>
|
|
139
|
+
<span class="select-chevron" aria-hidden="true">›</span>
|
|
140
|
+
</Button>
|
|
141
|
+
{#if open}
|
|
142
|
+
<ul class="select-panel" role="listbox" aria-label="Options">
|
|
143
|
+
{#each options as option, i}
|
|
144
|
+
<li
|
|
145
|
+
id="select-opt-{i}"
|
|
146
|
+
class="select-option"
|
|
147
|
+
class:selected={option.value === internalValue}
|
|
148
|
+
class:highlighted={i === highlightedIndex}
|
|
149
|
+
role="option"
|
|
150
|
+
aria-selected={option.value === internalValue}
|
|
151
|
+
onclick={() => handleSelect(option.value)}
|
|
152
|
+
>
|
|
153
|
+
{option.label}
|
|
154
|
+
{#if option.value === internalValue}
|
|
155
|
+
<span class="select-check" aria-hidden="true">✓</span>
|
|
156
|
+
{/if}
|
|
157
|
+
</li>
|
|
158
|
+
{/each}
|
|
159
|
+
</ul>
|
|
160
|
+
{/if}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<style>
|
|
164
|
+
.select {
|
|
165
|
+
position: relative;
|
|
166
|
+
width: 100%;
|
|
167
|
+
user-select: none;
|
|
168
|
+
|
|
169
|
+
:global(.btn) {
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
justify-content: space-between;
|
|
173
|
+
font-family: var(--mono);
|
|
174
|
+
font-size: 13px;
|
|
175
|
+
letter-spacing: 0.04em;
|
|
176
|
+
text-transform: uppercase;
|
|
177
|
+
background: var(--bg-sunken);
|
|
178
|
+
color: var(--ink);
|
|
179
|
+
border: 1px solid var(--rule-strong);
|
|
180
|
+
padding: 7px 10px;
|
|
181
|
+
width: 100%;
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
transition: border-color var(--transition);
|
|
184
|
+
border-radius: 0;
|
|
185
|
+
|
|
186
|
+
&.open {
|
|
187
|
+
border-left: 3px solid var(--amber);
|
|
188
|
+
padding-left: 8px;
|
|
189
|
+
|
|
190
|
+
.select-chevron {
|
|
191
|
+
transform: rotate(90deg);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
&.err {
|
|
196
|
+
border-color: var(--danger);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
&:disabled {
|
|
200
|
+
opacity: 0.4;
|
|
201
|
+
cursor: not-allowed;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.select-chevron {
|
|
207
|
+
font-family: var(--mono);
|
|
208
|
+
font-size: 14px;
|
|
209
|
+
color: var(--amber);
|
|
210
|
+
transition: transform var(--transition);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.select-panel {
|
|
214
|
+
position: absolute;
|
|
215
|
+
top: 100%;
|
|
216
|
+
left: 0;
|
|
217
|
+
right: 0;
|
|
218
|
+
z-index: 50;
|
|
219
|
+
background: var(--bg-elev);
|
|
220
|
+
border-top: none;
|
|
221
|
+
list-style: none;
|
|
222
|
+
margin: 0;
|
|
223
|
+
padding: 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.select-option {
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
justify-content: space-between;
|
|
230
|
+
font-family: var(--mono);
|
|
231
|
+
font-size: 12px;
|
|
232
|
+
letter-spacing: 0.04em;
|
|
233
|
+
text-transform: uppercase;
|
|
234
|
+
padding: 8px 10px;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
color: var(--ink-dim);
|
|
237
|
+
border-bottom: 1px solid var(--rule);
|
|
238
|
+
|
|
239
|
+
&:last-child {
|
|
240
|
+
border-bottom: none;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
&:hover {
|
|
244
|
+
background: var(--bg-rail);
|
|
245
|
+
color: var(--ink);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
&.selected {
|
|
249
|
+
color: var(--amber);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
&.highlighted {
|
|
253
|
+
background: var(--bg-rail);
|
|
254
|
+
color: var(--amber);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.select-check {
|
|
259
|
+
font-size: 11px;
|
|
260
|
+
color: var(--amber);
|
|
261
|
+
}
|
|
262
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
2
|
+
interface SelectOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "onchange"> {
|
|
7
|
+
/** Array of `{ value, label }` option objects. */
|
|
8
|
+
options: SelectOption[];
|
|
9
|
+
/** Currently selected value. */
|
|
10
|
+
value?: string;
|
|
11
|
+
/** Label shown when no value is selected. @default 'SELECT…' */
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Show the error (danger-border) state. @default false */
|
|
14
|
+
error?: boolean;
|
|
15
|
+
/** Disable the select — prevents opening the dropdown. @default false */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** Called with the new value string when the user selects an option. */
|
|
18
|
+
onchange?: (value: string) => void;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
declare const Select: import("svelte").Component<Props, {}, "">;
|
|
22
|
+
type Select = ReturnType<typeof Select>;
|
|
23
|
+
export default Select;
|