@lyeve-labs/ui-kit 0.12.1 → 0.13.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/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Card.svelte +43 -2
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -63
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +43 -7
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -35
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +259 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +31 -0
- package/dist/internal/field.js +38 -0
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +18 -10
- package/package.json +4 -2
- package/src/lib/styles/theme.css +18 -10
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One filter contract for every list-bearing control.
|
|
3
|
+
*
|
|
4
|
+
* MultiSelect and Autocomplete each spelled the same expression by hand, at
|
|
5
|
+
* MultiSelect.svelte:51-53 and Autocomplete.svelte:60-64:
|
|
6
|
+
*
|
|
7
|
+
* options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()))
|
|
8
|
+
*
|
|
9
|
+
* It reads the label and nothing else, so an option a user knows by its value,
|
|
10
|
+
* its airport code or a synonym could not be found. It compares raw code
|
|
11
|
+
* points, so a query of "cafe" missed an option labelled with an acute accent.
|
|
12
|
+
* It trims and lowercases the query once per option instead of once per
|
|
13
|
+
* keystroke. And it is not a prop, so a consumer whose list arrives already
|
|
14
|
+
* narrowed by a server query had no way to switch local filtering off or to say
|
|
15
|
+
* what a match means for their data. Both controls now call applyFilter, which
|
|
16
|
+
* takes a matcher the call site can replace or disable.
|
|
17
|
+
*
|
|
18
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
19
|
+
*/
|
|
20
|
+
/** What a matcher is told about the query, computed once per keystroke rather than per option. */
|
|
21
|
+
export interface FilterContext {
|
|
22
|
+
/** Exactly what the user typed, untouched. */
|
|
23
|
+
readonly query: string;
|
|
24
|
+
/**
|
|
25
|
+
* query trimmed, lowercased and NFD diacritic-folded. Precomputed: folding
|
|
26
|
+
* per option is O(n) work for a value that cannot change within a pass.
|
|
27
|
+
*/
|
|
28
|
+
readonly needle: string;
|
|
29
|
+
/** Position in the unfiltered array, so a rank-aware matcher can weight earlier entries. */
|
|
30
|
+
readonly index: number;
|
|
31
|
+
}
|
|
32
|
+
/** A matcher. Returning false drops the option. */
|
|
33
|
+
export type FilterFn<T> = (option: T, ctx: FilterContext) => boolean;
|
|
34
|
+
/**
|
|
35
|
+
* A filter prop: the default matcher, a replacement, or false to disable
|
|
36
|
+
* filtering entirely (the list is already filtered upstream, for instance by a
|
|
37
|
+
* server query).
|
|
38
|
+
*/
|
|
39
|
+
export type FilterInput<T> = FilterFn<T> | false | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Trim, lowercase, and fold diacritics through NFD so that a query of "e"
|
|
42
|
+
* matches an option spelled with an acute accent.
|
|
43
|
+
*
|
|
44
|
+
* NFD splits an accented letter into its base letter and a combining mark, so
|
|
45
|
+
* dropping the Mark category afterwards leaves the base letters and nothing
|
|
46
|
+
* else. That also folds marks in scripts where a mark distinguishes two words,
|
|
47
|
+
* which shows the user one option too many rather than hiding the one they were
|
|
48
|
+
* typing toward. A string that is only marks folds to nothing, which is the
|
|
49
|
+
* same answer as an empty query and is what the callers treat it as.
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalize(value: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* The default matcher: case- and accent-insensitive substring over the label,
|
|
54
|
+
* plus any keywords the option carries. Label-only matching is what ships
|
|
55
|
+
* today; keywords are additive and absent on every existing option, so adopting
|
|
56
|
+
* this changes no current list.
|
|
57
|
+
*
|
|
58
|
+
* An empty needle keeps every option. applyFilter answers an empty query before
|
|
59
|
+
* it reaches a matcher, so this case is here for a call site that composes the
|
|
60
|
+
* default into a matcher of its own.
|
|
61
|
+
*/
|
|
62
|
+
export declare function defaultFilter<T extends {
|
|
63
|
+
label: string;
|
|
64
|
+
keywords?: readonly string[];
|
|
65
|
+
}>(option: T, ctx: FilterContext): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Applies a FilterInput across a list, building the context once.
|
|
68
|
+
*
|
|
69
|
+
* An empty or whitespace-only query returns the input array unchanged, by
|
|
70
|
+
* identity, so a caller can compare references to skip work. A query that folds
|
|
71
|
+
* away to nothing counts as empty for the same reason: there is no needle left
|
|
72
|
+
* to look for, and matching every label against "" would drop nothing anyway.
|
|
73
|
+
*
|
|
74
|
+
* false is answered before the query is read, so a keystroke that is still in
|
|
75
|
+
* the box cannot reintroduce local filtering on a list the server already cut.
|
|
76
|
+
*/
|
|
77
|
+
export declare function applyFilter<T extends {
|
|
78
|
+
label: string;
|
|
79
|
+
keywords?: readonly string[];
|
|
80
|
+
}>(options: readonly T[], query: string, filter?: FilterInput<T>): readonly T[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One filter contract for every list-bearing control.
|
|
3
|
+
*
|
|
4
|
+
* MultiSelect and Autocomplete each spelled the same expression by hand, at
|
|
5
|
+
* MultiSelect.svelte:51-53 and Autocomplete.svelte:60-64:
|
|
6
|
+
*
|
|
7
|
+
* options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()))
|
|
8
|
+
*
|
|
9
|
+
* It reads the label and nothing else, so an option a user knows by its value,
|
|
10
|
+
* its airport code or a synonym could not be found. It compares raw code
|
|
11
|
+
* points, so a query of "cafe" missed an option labelled with an acute accent.
|
|
12
|
+
* It trims and lowercases the query once per option instead of once per
|
|
13
|
+
* keystroke. And it is not a prop, so a consumer whose list arrives already
|
|
14
|
+
* narrowed by a server query had no way to switch local filtering off or to say
|
|
15
|
+
* what a match means for their data. Both controls now call applyFilter, which
|
|
16
|
+
* takes a matcher the call site can replace or disable.
|
|
17
|
+
*
|
|
18
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Trim, lowercase, and fold diacritics through NFD so that a query of "e"
|
|
22
|
+
* matches an option spelled with an acute accent.
|
|
23
|
+
*
|
|
24
|
+
* NFD splits an accented letter into its base letter and a combining mark, so
|
|
25
|
+
* dropping the Mark category afterwards leaves the base letters and nothing
|
|
26
|
+
* else. That also folds marks in scripts where a mark distinguishes two words,
|
|
27
|
+
* which shows the user one option too many rather than hiding the one they were
|
|
28
|
+
* typing toward. A string that is only marks folds to nothing, which is the
|
|
29
|
+
* same answer as an empty query and is what the callers treat it as.
|
|
30
|
+
*/
|
|
31
|
+
export function normalize(value) {
|
|
32
|
+
return value.normalize('NFD').replace(/\p{M}/gu, '').trim().toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The default matcher: case- and accent-insensitive substring over the label,
|
|
36
|
+
* plus any keywords the option carries. Label-only matching is what ships
|
|
37
|
+
* today; keywords are additive and absent on every existing option, so adopting
|
|
38
|
+
* this changes no current list.
|
|
39
|
+
*
|
|
40
|
+
* An empty needle keeps every option. applyFilter answers an empty query before
|
|
41
|
+
* it reaches a matcher, so this case is here for a call site that composes the
|
|
42
|
+
* default into a matcher of its own.
|
|
43
|
+
*/
|
|
44
|
+
export function defaultFilter(option, ctx) {
|
|
45
|
+
if (ctx.needle === '')
|
|
46
|
+
return true;
|
|
47
|
+
if (normalize(option.label).includes(ctx.needle))
|
|
48
|
+
return true;
|
|
49
|
+
const keywords = option.keywords;
|
|
50
|
+
return keywords !== undefined && keywords.some((word) => normalize(word).includes(ctx.needle));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Applies a FilterInput across a list, building the context once.
|
|
54
|
+
*
|
|
55
|
+
* An empty or whitespace-only query returns the input array unchanged, by
|
|
56
|
+
* identity, so a caller can compare references to skip work. A query that folds
|
|
57
|
+
* away to nothing counts as empty for the same reason: there is no needle left
|
|
58
|
+
* to look for, and matching every label against "" would drop nothing anyway.
|
|
59
|
+
*
|
|
60
|
+
* false is answered before the query is read, so a keystroke that is still in
|
|
61
|
+
* the box cannot reintroduce local filtering on a list the server already cut.
|
|
62
|
+
*/
|
|
63
|
+
export function applyFilter(options, query, filter) {
|
|
64
|
+
if (filter === false)
|
|
65
|
+
return options;
|
|
66
|
+
const needle = normalize(query);
|
|
67
|
+
if (needle === '')
|
|
68
|
+
return options;
|
|
69
|
+
const match = filter ?? defaultFilter;
|
|
70
|
+
const kept = [];
|
|
71
|
+
for (let index = 0; index < options.length; index++) {
|
|
72
|
+
// index counts the input, not kept: a matcher that weights the top of the
|
|
73
|
+
// list has to see the same number for an option however many entries above
|
|
74
|
+
// it the query has already removed.
|
|
75
|
+
const option = options[index];
|
|
76
|
+
if (match(option, { query, needle, index }))
|
|
77
|
+
kept.push(option);
|
|
78
|
+
}
|
|
79
|
+
return kept;
|
|
80
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for how a page, a card, a table and a modal are
|
|
3
|
+
* spaced.
|
|
4
|
+
*
|
|
5
|
+
* Nothing in the library owned the page frame, so every page invented one. The
|
|
6
|
+
* same gutter ships in four spellings, five content caps are in use with no
|
|
7
|
+
* rule for picking between them, a section heading is spelled fourteen ways,
|
|
8
|
+
* and card surfaces are hand-rolled in nine paddings while Card itself goes
|
|
9
|
+
* unused. The components disagree with each other too: Card pads its header
|
|
10
|
+
* 16px down and its footer 12px down for no reason a reader can infer, and
|
|
11
|
+
* Modal insets its panel 20px where Dialog insets the same kind of panel 24px.
|
|
12
|
+
*
|
|
13
|
+
* Every value composes from a `--spacing-*` token rather than a Tailwind
|
|
14
|
+
* number. That is the only thing that makes the tokens real. Of the ten the
|
|
15
|
+
* theme declares, `--spacing-control` was the one with any uses, and it had
|
|
16
|
+
* them because the field contract composes from it.
|
|
17
|
+
*
|
|
18
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
19
|
+
*/
|
|
20
|
+
/** How much of the viewport a page's content is allowed to fill. */
|
|
21
|
+
export type PageWidth = 'narrow' | 'default' | 'wide' | 'full';
|
|
22
|
+
/**
|
|
23
|
+
* The gutter a page sits in, stated once.
|
|
24
|
+
*
|
|
25
|
+
* Two pages in the same shell started their content at different distances
|
|
26
|
+
* from the edge because each spelled its own gutter. The horizontal and
|
|
27
|
+
* vertical tokens resolve to the same 24px and keep separate names, so a
|
|
28
|
+
* design that wants a taller page gutter changes one token, not every page.
|
|
29
|
+
*/
|
|
30
|
+
export declare const PAGE_PAD = "mx-auto w-full px-page-x py-page-y";
|
|
31
|
+
/**
|
|
32
|
+
* Content cap by name. Four named slots replace the five raw max-w values
|
|
33
|
+
* chosen per page with no rule.
|
|
34
|
+
*
|
|
35
|
+
* `narrow` is one column: a form, a settings pane, a page of prose. `default`
|
|
36
|
+
* is a page of stacked cards. `wide` is a data page whose table needs the
|
|
37
|
+
* room. `full` opts out, for a canvas or a split pane that owns the viewport.
|
|
38
|
+
* The names carry the decision, so a page picks a role rather than a number.
|
|
39
|
+
*/
|
|
40
|
+
export declare const PAGE_WIDTH: Record<PageWidth, string>;
|
|
41
|
+
/**
|
|
42
|
+
* The vertical rhythm between a page's top-level sections. A property of the
|
|
43
|
+
* shell, so a page cannot choose its own.
|
|
44
|
+
*
|
|
45
|
+
* PageHeader already drops 32px below the title and every page that sets a
|
|
46
|
+
* section gap sets the same 32px, so the value was agreed and unstated. A gap
|
|
47
|
+
* on the shell also means adding a section is appending a child, rather than
|
|
48
|
+
* remembering to put a margin on it.
|
|
49
|
+
*/
|
|
50
|
+
export declare const PAGE_STACK = "flex flex-col gap-section";
|
|
51
|
+
/**
|
|
52
|
+
* The card surface, without its padding.
|
|
53
|
+
*
|
|
54
|
+
* Padding is separate because a card wrapping a table or a list wants its
|
|
55
|
+
* children flush to the border. `overflow-hidden` is deliberately absent: the
|
|
56
|
+
* focus ring sits 2px outside the element it belongs to, so a clipping surface
|
|
57
|
+
* crops the ring of every button inside it down to whichever edge fits.
|
|
58
|
+
*/
|
|
59
|
+
export declare const CARD_SURFACE = "bg-surface border border-line rounded-xl";
|
|
60
|
+
/**
|
|
61
|
+
* Card padding by name.
|
|
62
|
+
*
|
|
63
|
+
* `md` is the 20px the theme names `--spacing-card`: the measured mode across
|
|
64
|
+
* the card surfaces in use, and what Card itself paints. `lg` is the page
|
|
65
|
+
* gutter, so a card padded `lg` holds its content on the same rhythm as the
|
|
66
|
+
* page around it. Nine hand-rolled paddings collapse onto these four.
|
|
67
|
+
*/
|
|
68
|
+
export declare const CARD_PAD: Record<'none' | 'sm' | 'md' | 'lg', string>;
|
|
69
|
+
/**
|
|
70
|
+
* The band above a card's content.
|
|
71
|
+
*
|
|
72
|
+
* Card insets its header 20px across and 16px down, and its footer 20px across
|
|
73
|
+
* and 12px down. Nothing tells the two bands apart, so they share one inset
|
|
74
|
+
* here and differ only in which edge carries the rule.
|
|
75
|
+
*/
|
|
76
|
+
export declare const CARD_HEADER = "px-card py-card-sm border-b border-line";
|
|
77
|
+
/** The band below a card's content. CARD_HEADER's inset, with the rule on top. */
|
|
78
|
+
export declare const CARD_FOOTER = "px-card py-card-sm border-t border-line bg-surface-2/40";
|
|
79
|
+
/**
|
|
80
|
+
* A placeholder inside a card, where three spellings of the same centred muted
|
|
81
|
+
* line currently ship.
|
|
82
|
+
*
|
|
83
|
+
* An empty list is not an error, so it reads as muted body copy and not as a
|
|
84
|
+
* warning. The section gap above and below keeps a card holding nothing from
|
|
85
|
+
* collapsing to a single line of text.
|
|
86
|
+
*/
|
|
87
|
+
export declare const CARD_EMPTY = "py-section text-center text-sm text-muted";
|
|
88
|
+
/**
|
|
89
|
+
* The head cell of a table: its padding and the type treatment that marks it
|
|
90
|
+
* as a label rather than data.
|
|
91
|
+
*
|
|
92
|
+
* The hand-rolled tables split four ways on cell padding, so two tables on one
|
|
93
|
+
* page ran at different row heights. Horizontal is the compact card step, so a
|
|
94
|
+
* full-bleed table inside a card lines its first column up with the card's own
|
|
95
|
+
* text. Vertical is the control step: 12px and 8px were both already in use in
|
|
96
|
+
* near equal numbers, and 8px is the one the token scale names.
|
|
97
|
+
*/
|
|
98
|
+
export declare const TABLE_CELL_HEAD = "px-card-sm py-input-y text-xs font-medium uppercase tracking-wider whitespace-nowrap text-faint";
|
|
99
|
+
/** The body cell of a table. TABLE_CELL_HEAD's padding, at body weight and colour. */
|
|
100
|
+
export declare const TABLE_CELL_BODY = "px-card-sm py-input-y text-fg align-middle";
|
|
101
|
+
/**
|
|
102
|
+
* The gutter every modal surface uses. Modal paints 20px and Dialog paints
|
|
103
|
+
* 24px for the same kind of surface.
|
|
104
|
+
*
|
|
105
|
+
* A dialog opened over a modal showed both insets at once. A modal panel is a
|
|
106
|
+
* card lifted off the page, so a banded modal takes CARD_HEADER and
|
|
107
|
+
* CARD_FOOTER, which resolve to this same inset.
|
|
108
|
+
*/
|
|
109
|
+
export declare const MODAL_PAD = "px-card py-card-sm";
|
|
110
|
+
/**
|
|
111
|
+
* A section heading below the page title. Level 2 sits under the title, level 3
|
|
112
|
+
* inside a card.
|
|
113
|
+
*
|
|
114
|
+
* Fourteen distinct class strings serve this role, so two sections on the same
|
|
115
|
+
* page can render at different sizes and weights. Taking the level rather than
|
|
116
|
+
* a free-form string means the class cannot disagree with the heading element
|
|
117
|
+
* the caller is already writing.
|
|
118
|
+
*/
|
|
119
|
+
export declare function sectionHeading(level: 2 | 3): string;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for how a page, a card, a table and a modal are
|
|
3
|
+
* spaced.
|
|
4
|
+
*
|
|
5
|
+
* Nothing in the library owned the page frame, so every page invented one. The
|
|
6
|
+
* same gutter ships in four spellings, five content caps are in use with no
|
|
7
|
+
* rule for picking between them, a section heading is spelled fourteen ways,
|
|
8
|
+
* and card surfaces are hand-rolled in nine paddings while Card itself goes
|
|
9
|
+
* unused. The components disagree with each other too: Card pads its header
|
|
10
|
+
* 16px down and its footer 12px down for no reason a reader can infer, and
|
|
11
|
+
* Modal insets its panel 20px where Dialog insets the same kind of panel 24px.
|
|
12
|
+
*
|
|
13
|
+
* Every value composes from a `--spacing-*` token rather than a Tailwind
|
|
14
|
+
* number. That is the only thing that makes the tokens real. Of the ten the
|
|
15
|
+
* theme declares, `--spacing-control` was the one with any uses, and it had
|
|
16
|
+
* them because the field contract composes from it.
|
|
17
|
+
*
|
|
18
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The gutter a page sits in, stated once.
|
|
22
|
+
*
|
|
23
|
+
* Two pages in the same shell started their content at different distances
|
|
24
|
+
* from the edge because each spelled its own gutter. The horizontal and
|
|
25
|
+
* vertical tokens resolve to the same 24px and keep separate names, so a
|
|
26
|
+
* design that wants a taller page gutter changes one token, not every page.
|
|
27
|
+
*/
|
|
28
|
+
export const PAGE_PAD = 'mx-auto w-full px-page-x py-page-y';
|
|
29
|
+
/**
|
|
30
|
+
* Content cap by name. Four named slots replace the five raw max-w values
|
|
31
|
+
* chosen per page with no rule.
|
|
32
|
+
*
|
|
33
|
+
* `narrow` is one column: a form, a settings pane, a page of prose. `default`
|
|
34
|
+
* is a page of stacked cards. `wide` is a data page whose table needs the
|
|
35
|
+
* room. `full` opts out, for a canvas or a split pane that owns the viewport.
|
|
36
|
+
* The names carry the decision, so a page picks a role rather than a number.
|
|
37
|
+
*/
|
|
38
|
+
export const PAGE_WIDTH = {
|
|
39
|
+
narrow: 'max-w-3xl',
|
|
40
|
+
default: 'max-w-5xl',
|
|
41
|
+
wide: 'max-w-7xl',
|
|
42
|
+
full: 'max-w-full',
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The vertical rhythm between a page's top-level sections. A property of the
|
|
46
|
+
* shell, so a page cannot choose its own.
|
|
47
|
+
*
|
|
48
|
+
* PageHeader already drops 32px below the title and every page that sets a
|
|
49
|
+
* section gap sets the same 32px, so the value was agreed and unstated. A gap
|
|
50
|
+
* on the shell also means adding a section is appending a child, rather than
|
|
51
|
+
* remembering to put a margin on it.
|
|
52
|
+
*/
|
|
53
|
+
export const PAGE_STACK = 'flex flex-col gap-section';
|
|
54
|
+
/**
|
|
55
|
+
* The card surface, without its padding.
|
|
56
|
+
*
|
|
57
|
+
* Padding is separate because a card wrapping a table or a list wants its
|
|
58
|
+
* children flush to the border. `overflow-hidden` is deliberately absent: the
|
|
59
|
+
* focus ring sits 2px outside the element it belongs to, so a clipping surface
|
|
60
|
+
* crops the ring of every button inside it down to whichever edge fits.
|
|
61
|
+
*/
|
|
62
|
+
export const CARD_SURFACE = 'bg-surface border border-line rounded-xl';
|
|
63
|
+
/**
|
|
64
|
+
* Card padding by name.
|
|
65
|
+
*
|
|
66
|
+
* `md` is the 20px the theme names `--spacing-card`: the measured mode across
|
|
67
|
+
* the card surfaces in use, and what Card itself paints. `lg` is the page
|
|
68
|
+
* gutter, so a card padded `lg` holds its content on the same rhythm as the
|
|
69
|
+
* page around it. Nine hand-rolled paddings collapse onto these four.
|
|
70
|
+
*/
|
|
71
|
+
export const CARD_PAD = {
|
|
72
|
+
none: '',
|
|
73
|
+
sm: 'p-card-sm',
|
|
74
|
+
md: 'p-card',
|
|
75
|
+
lg: 'p-page-x',
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* The band above a card's content.
|
|
79
|
+
*
|
|
80
|
+
* Card insets its header 20px across and 16px down, and its footer 20px across
|
|
81
|
+
* and 12px down. Nothing tells the two bands apart, so they share one inset
|
|
82
|
+
* here and differ only in which edge carries the rule.
|
|
83
|
+
*/
|
|
84
|
+
export const CARD_HEADER = 'px-card py-card-sm border-b border-line';
|
|
85
|
+
/** The band below a card's content. CARD_HEADER's inset, with the rule on top. */
|
|
86
|
+
export const CARD_FOOTER = 'px-card py-card-sm border-t border-line bg-surface-2/40';
|
|
87
|
+
/**
|
|
88
|
+
* A placeholder inside a card, where three spellings of the same centred muted
|
|
89
|
+
* line currently ship.
|
|
90
|
+
*
|
|
91
|
+
* An empty list is not an error, so it reads as muted body copy and not as a
|
|
92
|
+
* warning. The section gap above and below keeps a card holding nothing from
|
|
93
|
+
* collapsing to a single line of text.
|
|
94
|
+
*/
|
|
95
|
+
export const CARD_EMPTY = 'py-section text-center text-sm text-muted';
|
|
96
|
+
/**
|
|
97
|
+
* The head cell of a table: its padding and the type treatment that marks it
|
|
98
|
+
* as a label rather than data.
|
|
99
|
+
*
|
|
100
|
+
* The hand-rolled tables split four ways on cell padding, so two tables on one
|
|
101
|
+
* page ran at different row heights. Horizontal is the compact card step, so a
|
|
102
|
+
* full-bleed table inside a card lines its first column up with the card's own
|
|
103
|
+
* text. Vertical is the control step: 12px and 8px were both already in use in
|
|
104
|
+
* near equal numbers, and 8px is the one the token scale names.
|
|
105
|
+
*/
|
|
106
|
+
export const TABLE_CELL_HEAD = 'px-card-sm py-input-y text-xs font-medium uppercase tracking-wider whitespace-nowrap text-faint';
|
|
107
|
+
/** The body cell of a table. TABLE_CELL_HEAD's padding, at body weight and colour. */
|
|
108
|
+
export const TABLE_CELL_BODY = 'px-card-sm py-input-y text-fg align-middle';
|
|
109
|
+
/**
|
|
110
|
+
* The gutter every modal surface uses. Modal paints 20px and Dialog paints
|
|
111
|
+
* 24px for the same kind of surface.
|
|
112
|
+
*
|
|
113
|
+
* A dialog opened over a modal showed both insets at once. A modal panel is a
|
|
114
|
+
* card lifted off the page, so a banded modal takes CARD_HEADER and
|
|
115
|
+
* CARD_FOOTER, which resolve to this same inset.
|
|
116
|
+
*/
|
|
117
|
+
export const MODAL_PAD = 'px-card py-card-sm';
|
|
118
|
+
/**
|
|
119
|
+
* A section heading below the page title. Level 2 sits under the title, level 3
|
|
120
|
+
* inside a card.
|
|
121
|
+
*
|
|
122
|
+
* Fourteen distinct class strings serve this role, so two sections on the same
|
|
123
|
+
* page can render at different sizes and weights. Taking the level rather than
|
|
124
|
+
* a free-form string means the class cannot disagree with the heading element
|
|
125
|
+
* the caller is already writing.
|
|
126
|
+
*/
|
|
127
|
+
export function sectionHeading(level) {
|
|
128
|
+
// Level 3 drops a size rather than a weight. Inside a card it sits under the
|
|
129
|
+
// card's own semibold title, and two semibold lines at the same size read as
|
|
130
|
+
// one heading broken in half.
|
|
131
|
+
return level === 2 ? 'text-lg font-semibold text-fg' : 'text-sm font-semibold text-fg';
|
|
132
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One owner for the open state, the active row, the keyboard model and the
|
|
3
|
+
* dismissal that every list-bearing control needs.
|
|
4
|
+
*
|
|
5
|
+
* MultiSelect, Autocomplete, DatePicker and Dropdown each hand-rolled all four,
|
|
6
|
+
* and every copy is wrong somewhere different. The dismiss effect is written
|
|
7
|
+
* out four times: MultiSelect.svelte:84-93, DatePicker.svelte:151-160 and
|
|
8
|
+
* Dropdown.svelte:45-54 are byte identical, and Autocomplete.svelte:117-120 is
|
|
9
|
+
* the same minus the keydown, so Escape does nothing there at all.
|
|
10
|
+
* Autocomplete.svelte:90-107 is the kit's only arrow-key implementation and it
|
|
11
|
+
* is incomplete: ArrowUp on a closed list decrements the index without opening
|
|
12
|
+
* anything, Home and End do nothing, there is no typeahead and there is no
|
|
13
|
+
* wrap. Nothing in the kit sets aria-activedescendant, so a screen reader is
|
|
14
|
+
* never told which row the keyboard is resting on, and Autocomplete marks that
|
|
15
|
+
* row with a background tint alone, which reads 1.09:1. The rows are buttons
|
|
16
|
+
* carrying role="option" and no tabindex, so Tab walks into the list instead of
|
|
17
|
+
* leaving the field. Autocomplete.svelte:146 closes on a 150ms blur timer, so
|
|
18
|
+
* clicking an option works only because mousedown-to-click beats the timer. And
|
|
19
|
+
* no copy stops the Escape event, so a listbox inside a Modal closes both.
|
|
20
|
+
*
|
|
21
|
+
* One thing deliberately stays at the call site: what a selection means. This
|
|
22
|
+
* fires onSelect and leaves the list open, because MultiSelect collects several
|
|
23
|
+
* values in one pass and a factory that closed on every pick could not serve
|
|
24
|
+
* it. A single-value control calls close('select') from its own onSelect, which
|
|
25
|
+
* is why that reason exists.
|
|
26
|
+
*
|
|
27
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
28
|
+
*/
|
|
29
|
+
/** The least a row has to be for the keyboard model to work on it. */
|
|
30
|
+
export interface ListboxItem {
|
|
31
|
+
value: string;
|
|
32
|
+
label: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Why the list closed. A consumer that resets a search query on dismissal but
|
|
37
|
+
* keeps it on a pick needs to tell the two apart, and the four controls each
|
|
38
|
+
* guessed.
|
|
39
|
+
*/
|
|
40
|
+
export type ListboxCloseReason = 'escape' | 'select' | 'outside' | 'focusout' | 'tab';
|
|
41
|
+
export interface ListboxConfig<T extends ListboxItem> {
|
|
42
|
+
items: () => readonly T[];
|
|
43
|
+
/** Stable across the SSR boundary. Pass $props.id(). */
|
|
44
|
+
baseId: () => string;
|
|
45
|
+
onSelect: (item: T, index: number) => void;
|
|
46
|
+
onOpenChange?: (open: boolean) => void;
|
|
47
|
+
onClose?: (reason: ListboxCloseReason) => void;
|
|
48
|
+
/** Typeahead jumps to the next item whose label starts with the typed run. Off for a control with its own text input, where letters are query text. */
|
|
49
|
+
typeahead?: () => boolean;
|
|
50
|
+
/** The active row wraps past the ends. */
|
|
51
|
+
loop?: () => boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface Listbox {
|
|
54
|
+
readonly open: boolean;
|
|
55
|
+
readonly activeIndex: number;
|
|
56
|
+
/** Attributes for the trigger or the combobox input. */
|
|
57
|
+
readonly triggerAttrs: Record<string, string | undefined>;
|
|
58
|
+
/** Attributes for the panel. */
|
|
59
|
+
readonly listAttrs: Record<string, string | undefined>;
|
|
60
|
+
/** Attributes for one row. tabindex is -1: focus stays on the trigger and position travels by aria-activedescendant. */
|
|
61
|
+
optionAttrs(index: number): Record<string, string | number | undefined>;
|
|
62
|
+
openList(active?: number): void;
|
|
63
|
+
close(reason: ListboxCloseReason): void;
|
|
64
|
+
toggle(): void;
|
|
65
|
+
setActive(index: number): void;
|
|
66
|
+
/** Returns true when it consumed the event. */
|
|
67
|
+
onkeydown(event: KeyboardEvent): boolean;
|
|
68
|
+
/** use:listbox.anchor on the wrapper. Registers outside-click and focusout dismissal. */
|
|
69
|
+
anchor: (node: HTMLElement) => {
|
|
70
|
+
destroy(): void;
|
|
71
|
+
};
|
|
72
|
+
/** use:listbox.panel on the panel. Keeps the active row scrolled into view. */
|
|
73
|
+
panel: (node: HTMLElement) => {
|
|
74
|
+
destroy(): void;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export declare function createListbox<T extends ListboxItem>(config: ListboxConfig<T>): Listbox;
|