@lyeve-labs/ui-kit 0.11.2 → 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.
Files changed (101) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/Autocomplete.svelte +191 -125
  4. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  5. package/dist/components/Button.svelte +26 -4
  6. package/dist/components/Card.svelte +61 -3
  7. package/dist/components/Card.svelte.d.ts +24 -2
  8. package/dist/components/Checkbox.svelte +174 -59
  9. package/dist/components/Checkbox.svelte.d.ts +20 -3
  10. package/dist/components/CheckboxGroup.svelte +162 -0
  11. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  12. package/dist/components/Collapsible.svelte +142 -0
  13. package/dist/components/Collapsible.svelte.d.ts +32 -0
  14. package/dist/components/CopyButton.svelte +126 -0
  15. package/dist/components/CopyButton.svelte.d.ts +14 -0
  16. package/dist/components/DatePicker.svelte +48 -6
  17. package/dist/components/DateTimePicker.svelte +337 -0
  18. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  19. package/dist/components/DescriptionList.svelte +78 -0
  20. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  21. package/dist/components/Drawer.svelte +15 -4
  22. package/dist/components/Field.svelte +104 -0
  23. package/dist/components/Field.svelte.d.ts +46 -0
  24. package/dist/components/FileInput.svelte +5 -2
  25. package/dist/components/FormMessage.svelte +85 -0
  26. package/dist/components/FormMessage.svelte.d.ts +11 -0
  27. package/dist/components/Input.svelte +1 -1
  28. package/dist/components/Label.svelte +7 -1
  29. package/dist/components/Label.svelte.d.ts +6 -0
  30. package/dist/components/Modal.svelte +25 -8
  31. package/dist/components/MultiSelect.svelte +199 -109
  32. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  33. package/dist/components/NumberInput.svelte +8 -4
  34. package/dist/components/PageHeader.svelte +37 -4
  35. package/dist/components/PageHeader.svelte.d.ts +15 -0
  36. package/dist/components/PageShell.svelte +85 -0
  37. package/dist/components/PageShell.svelte.d.ts +38 -0
  38. package/dist/components/Pagination.svelte +58 -17
  39. package/dist/components/Panel.svelte +101 -0
  40. package/dist/components/Panel.svelte.d.ts +39 -0
  41. package/dist/components/PasswordInput.svelte +139 -0
  42. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  43. package/dist/components/Radio.svelte +152 -32
  44. package/dist/components/Radio.svelte.d.ts +16 -1
  45. package/dist/components/RadioGroup.svelte +118 -71
  46. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  47. package/dist/components/SectionHeading.svelte +39 -0
  48. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  49. package/dist/components/SegmentedControl.svelte +194 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  51. package/dist/components/Select.svelte +471 -46
  52. package/dist/components/Select.svelte.d.ts +95 -6
  53. package/dist/components/SidebarNav.svelte +259 -0
  54. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  55. package/dist/components/Stat.svelte +53 -2
  56. package/dist/components/Stat.svelte.d.ts +31 -0
  57. package/dist/components/Textarea.svelte +1 -1
  58. package/dist/components/TimePicker.svelte +480 -0
  59. package/dist/components/TimePicker.svelte.d.ts +23 -0
  60. package/dist/components/Toaster.svelte +9 -2
  61. package/dist/components/Toggle.svelte +5 -1
  62. package/dist/components/Toggle.svelte.d.ts +2 -0
  63. package/dist/components/Toolbar.svelte +39 -0
  64. package/dist/components/Toolbar.svelte.d.ts +26 -0
  65. package/dist/components/Tooltip.svelte +48 -12
  66. package/dist/components/TreeView.svelte +339 -0
  67. package/dist/components/TreeView.svelte.d.ts +37 -0
  68. package/dist/components/dialog/Dialog.svelte +15 -58
  69. package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
  70. package/dist/components/dialog/dialog-manager.svelte.js +21 -21
  71. package/dist/index.d.ts +25 -1
  72. package/dist/index.js +20 -1
  73. package/dist/internal/calendar.d.ts +119 -0
  74. package/dist/internal/calendar.js +225 -0
  75. package/dist/internal/choice.d.ts +136 -0
  76. package/dist/internal/choice.js +179 -0
  77. package/dist/internal/field.d.ts +31 -0
  78. package/dist/internal/field.js +42 -1
  79. package/dist/internal/filter.d.ts +80 -0
  80. package/dist/internal/filter.js +80 -0
  81. package/dist/internal/layout.d.ts +119 -0
  82. package/dist/internal/layout.js +132 -0
  83. package/dist/internal/listbox.svelte.d.ts +77 -0
  84. package/dist/internal/listbox.svelte.js +438 -0
  85. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  86. package/dist/internal/nav-expansion.svelte.js +144 -0
  87. package/dist/internal/nav-tree.d.ts +68 -0
  88. package/dist/internal/nav-tree.js +102 -0
  89. package/dist/internal/overlay.d.ts +25 -0
  90. package/dist/internal/overlay.js +92 -0
  91. package/dist/internal/panel.d.ts +100 -0
  92. package/dist/internal/panel.js +109 -0
  93. package/dist/internal/rollup.d.ts +52 -0
  94. package/dist/internal/rollup.js +67 -0
  95. package/dist/internal/time.d.ts +103 -0
  96. package/dist/internal/time.js +166 -0
  97. package/dist/internal/tree.d.ts +86 -0
  98. package/dist/internal/tree.js +111 -0
  99. package/dist/styles/theme.css +66 -25
  100. package/package.json +4 -2
  101. package/src/lib/styles/theme.css +66 -25
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The single source of truth for how a checkbox or a radio looks and announces
3
+ * itself.
4
+ *
5
+ * Checkbox, Radio and RadioGroup paint one control three ways. The two label
6
+ * wrappers hold the same utilities in two orders, Checkbox marks a required
7
+ * option and Radio marks nothing, RadioGroup rests its box on `border-line`
8
+ * where the other two use `border-line-strong`, and RadioGroup still keeps its
9
+ * focus ring inside the selected ternary. That last one is the defect Checkbox
10
+ * and Radio were already fixed for: selecting an option deleted the only
11
+ * indicator a keyboard user had, and in a list of a hundred options that loses
12
+ * your place entirely. Composing from here means a fourth choice control cannot
13
+ * reintroduce any of it.
14
+ *
15
+ * Not exported from the package entry point - this is an implementation detail.
16
+ */
17
+ import { FIELD_HINT } from './field.js';
18
+ /**
19
+ * The box and the icon slot share one size map, so the label text starts at the
20
+ * same x whether or not an option carries an icon. 3.5 is 14px, 4 is 16px and 5
21
+ * is 20px on the 4px grid.
22
+ */
23
+ const CHOICE_SQUARE = {
24
+ sm: 'h-3.5 w-3.5',
25
+ md: 'h-4 w-4',
26
+ lg: 'h-5 w-5',
27
+ };
28
+ const WRAP_INLINE = 'inline-flex select-none items-start gap-2.5';
29
+ const WRAP_CARD = 'flex select-none items-start gap-2.5 rounded-lg border p-3 transition-colors duration-150';
30
+ /**
31
+ * The clickable wrapper. Card is a bordered option the whole surface of which
32
+ * is the target.
33
+ *
34
+ * The cursor is a ternary rather than a `cursor-pointer` that a disabled state
35
+ * appends `cursor-not-allowed` to. Both set the same property, so a class list
36
+ * carrying both resolves by stylesheet order and not by the order written here,
37
+ * which is how a disabled option kept offering a pointer.
38
+ */
39
+ export function choiceWrap(variant, checked, disabled) {
40
+ const cursor = disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer';
41
+ if (variant === 'card') {
42
+ // A selected card is tinted rather than filled: the label sits on it, and
43
+ // solid brand behind body text drops the contrast below AA.
44
+ const paint = checked ? 'border-brand bg-brand/8' : 'border-line-strong bg-surface-2';
45
+ return `${WRAP_CARD} ${paint} ${cursor}`;
46
+ }
47
+ return `${WRAP_INLINE} ${cursor}`;
48
+ }
49
+ /**
50
+ * The transparent input that sits over the painted box and carries every native
51
+ * semantic.
52
+ *
53
+ * `sr-only` leaves the input 1x1 and buried under the box that replaces it, so
54
+ * a click aimed at what the user sees lands on nothing. It covers the box
55
+ * instead, and `peer` is what lets the box below read its focus state.
56
+ */
57
+ export const CHOICE_INPUT = 'peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed';
58
+ const BOX_BASE = 'pointer-events-none flex shrink-0 items-center justify-center transition-colors duration-150';
59
+ /**
60
+ * The painted box or circle. Square for a checkbox, round for a radio, sized by
61
+ * ChoiceSize.
62
+ *
63
+ * The resting border is `border-line-strong`. `border-line` reads 1.25:1
64
+ * against the page, and the border is the only thing identifying an empty
65
+ * checkbox as a control, so at that contrast the control is invisible until it
66
+ * is used. A radio keeps a 2px ring because its selected state is a dot inside
67
+ * the ring rather than a fill, and a 1px ring reads as a smudge beside the dot
68
+ * at 14px.
69
+ *
70
+ * `mixed` paints exactly what `checked` paints. A part-selected parent is on,
71
+ * not a third state with a colour of its own; only the mark it holds differs.
72
+ */
73
+ export function choiceBox(kind, size, checked, mixed) {
74
+ const on = checked || mixed;
75
+ const shape = kind === 'radio' ? 'rounded-full border-2' : 'rounded-xs border';
76
+ const paint = on
77
+ ? kind === 'radio'
78
+ ? 'border-brand bg-surface-2'
79
+ : 'border-brand bg-brand text-ink'
80
+ : 'border-line-strong bg-surface-2';
81
+ return `${BOX_BASE} ${CHOICE_SQUARE[size]} ${shape} ${paint} ${CHOICE_FOCUS}`;
82
+ }
83
+ /**
84
+ * The focus ring, stated ONCE and outside every checked branch.
85
+ *
86
+ * The ring lived inside the checked ternary in three components, so choosing an
87
+ * option removed the only indicator a keyboard user had. It is a ring and not a
88
+ * border colour because a selected box already carries a brand border, which
89
+ * leaves a border-only focus state with nothing to say.
90
+ */
91
+ export const CHOICE_FOCUS = 'peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-brand peer-focus-visible:outline-offset-2';
92
+ /**
93
+ * The label stack beside or under the box.
94
+ *
95
+ * Checkbox and Radio both build this, in different word orders, which is how
96
+ * two controls in one form ended up with different gaps between a label and its
97
+ * description.
98
+ */
99
+ export const CHOICE_LABEL_STACK = 'flex flex-col gap-0.5';
100
+ /**
101
+ * The label text, sized with the control.
102
+ *
103
+ * An option label is not a field label: it names one choice rather than the
104
+ * group, so it stays regular weight and FIELD_LABEL keeps the medium.
105
+ */
106
+ export function choiceLabel(size) {
107
+ const scale = {
108
+ sm: 'text-xs',
109
+ md: 'text-sm',
110
+ lg: 'text-base',
111
+ };
112
+ return `${scale[size]} text-fg`;
113
+ }
114
+ /**
115
+ * Secondary line under the label.
116
+ *
117
+ * The same class the hint under a text field uses, taken from field.ts rather
118
+ * than respelled, because a checkbox hint and an input hint sitting in one form
119
+ * have no reason to differ and had already drifted apart once.
120
+ */
121
+ export const CHOICE_DESCRIPTION = FIELD_HINT;
122
+ /**
123
+ * The icon slot ahead of the label.
124
+ *
125
+ * Fixed to the box size so the label starts at the same x in every row of a
126
+ * list, and given no colour of its own so it inherits the label and dims with
127
+ * the wrapper when the option is disabled.
128
+ */
129
+ export function choiceIcon(size) {
130
+ return `flex shrink-0 items-center justify-center ${CHOICE_SQUARE[size]}`;
131
+ }
132
+ /**
133
+ * Pixel size for a lucide icon at each ChoiceSize, so a consumer does not
134
+ * guess.
135
+ *
136
+ * A lucide icon takes a number, not a class, so the slot above cannot size it.
137
+ * These are the same 14, 16 and 20 the slot reserves; an icon rendered at any
138
+ * other size overflows the slot or floats inside it.
139
+ */
140
+ export const CHOICE_ICON_PX = {
141
+ sm: 14,
142
+ md: 16,
143
+ lg: 20,
144
+ };
145
+ /**
146
+ * The fieldset a group renders, so the set is announced as a group rather than
147
+ * as loose controls.
148
+ *
149
+ * `min-w-0` is load bearing: a fieldset defaults to `min-width: min-content`,
150
+ * so one long option label widened the whole group past its column instead of
151
+ * wrapping.
152
+ */
153
+ export const CHOICE_GROUP = 'flex min-w-0 flex-col gap-2';
154
+ /**
155
+ * The row or column the options sit in.
156
+ *
157
+ * A horizontal group gets a wider inline gap than block gap: options read as
158
+ * separate choices across a row and as one list down a column, and an equal gap
159
+ * in both axes makes a wrapped row look like a grid.
160
+ */
161
+ export function choiceGroupList(orientation) {
162
+ return orientation === 'horizontal'
163
+ ? 'flex flex-row flex-wrap gap-x-5 gap-y-2'
164
+ : 'flex flex-col gap-2';
165
+ }
166
+ /**
167
+ * The check and the mixed marks, as SVG path data on the box's own grid.
168
+ *
169
+ * Drawn as paths because the consistency suite rejects a Unicode check outright,
170
+ * and because a font glyph lands at a different optical weight from every other
171
+ * icon in the library. Both sit in a 10 by 8 viewBox centred in the box, at
172
+ * stroke width 1.5 with round caps, so the two marks swap without the box
173
+ * shifting. The mixed bar stops short of the edges so its round caps do not
174
+ * touch the border.
175
+ */
176
+ export const CHOICE_MARK = {
177
+ check: 'M1 4l3 3 5-6',
178
+ mixed: 'M1.5 4h7',
179
+ };
@@ -46,3 +46,34 @@ export declare function controlBorder(error: boolean): string;
46
46
  * reader announced the control and never the reason it was rejected.
47
47
  */
48
48
  export declare function describedBy(id: string | undefined, error?: string, hint?: string): string | undefined;
49
+ /**
50
+ * A control built from several inputs rather than from one.
51
+ *
52
+ * CONTROL_BASE is `w-full` and assumes a single input fills the control, so a
53
+ * time field composed from it stretched three two-character segments across a
54
+ * whole column and left the separators floating in the gaps. This shrink-wraps
55
+ * its content instead and keeps `h-control`, so a segmented control and an
56
+ * Input placed in the same row still line up.
57
+ *
58
+ * The focus border is driven by `focus-within`. The wrapper is a div, it never
59
+ * receives focus itself, and `focus:border-brand` on it can therefore never
60
+ * match: the control would sit at its resting border while one of its segments
61
+ * held the caret.
62
+ */
63
+ export declare const CONTROL_SEGMENTED: string;
64
+ /** As controlBorder, for a wrapper that is only ever focused through a child. */
65
+ export declare function segmentedBorder(error: boolean): string;
66
+ /**
67
+ * One segment inside CONTROL_SEGMENTED.
68
+ *
69
+ * It carries no width. A two-digit segment needs a fixed one so the control
70
+ * does not resize as the user types, and an AM/PM select needs to size to its
71
+ * own text; stating a width here would mean one of the two overriding it, and
72
+ * two width utilities on one element resolve by the order Tailwind emits them
73
+ * rather than the order they were written.
74
+ *
75
+ * The focus ring is inset. The segment sits flush inside the wrapper's border,
76
+ * so the global 2px outset outline would be drawn over that border and clipped
77
+ * by the wrapper's own rounding.
78
+ */
79
+ export declare const CONTROL_SEGMENT: string;
@@ -41,7 +41,10 @@ export const CONTROL_MULTILINE = 'w-full min-h-control rounded-lg bg-surface-2 b
41
41
  * `border-danger/70`.
42
42
  */
43
43
  export function controlBorder(error) {
44
- return error ? 'border-danger focus:border-danger' : 'border-line focus:border-brand';
44
+ // line-strong, not line: at 1.25:1 against the page the resting border was
45
+ // the only thing marking the control and it failed SC 1.4.11. line stays the
46
+ // divider colour, where there is no control to identify.
47
+ return error ? 'border-danger focus:border-danger' : 'border-line-strong focus:border-brand';
45
48
  }
46
49
  /**
47
50
  * Wires a control to whichever of its hint or error is on screen.
@@ -60,3 +63,41 @@ export function describedBy(id, error, hint) {
60
63
  return `${id}-hint`;
61
64
  return undefined;
62
65
  }
66
+ /**
67
+ * A control built from several inputs rather than from one.
68
+ *
69
+ * CONTROL_BASE is `w-full` and assumes a single input fills the control, so a
70
+ * time field composed from it stretched three two-character segments across a
71
+ * whole column and left the separators floating in the gaps. This shrink-wraps
72
+ * its content instead and keeps `h-control`, so a segmented control and an
73
+ * Input placed in the same row still line up.
74
+ *
75
+ * The focus border is driven by `focus-within`. The wrapper is a div, it never
76
+ * receives focus itself, and `focus:border-brand` on it can therefore never
77
+ * match: the control would sit at its resting border while one of its segments
78
+ * held the caret.
79
+ */
80
+ export const CONTROL_SEGMENTED = 'inline-flex h-control items-center gap-0.5 rounded-lg bg-surface-2 border px-3 ' +
81
+ 'text-sm text-fg transition-colors duration-150';
82
+ /** As controlBorder, for a wrapper that is only ever focused through a child. */
83
+ export function segmentedBorder(error) {
84
+ return error
85
+ ? 'border-danger focus-within:border-danger'
86
+ : 'border-line-strong focus-within:border-brand';
87
+ }
88
+ /**
89
+ * One segment inside CONTROL_SEGMENTED.
90
+ *
91
+ * It carries no width. A two-digit segment needs a fixed one so the control
92
+ * does not resize as the user types, and an AM/PM select needs to size to its
93
+ * own text; stating a width here would mean one of the two overriding it, and
94
+ * two width utilities on one element resolve by the order Tailwind emits them
95
+ * rather than the order they were written.
96
+ *
97
+ * The focus ring is inset. The segment sits flush inside the wrapper's border,
98
+ * so the global 2px outset outline would be drawn over that border and clipped
99
+ * by the wrapper's own rounding.
100
+ */
101
+ export const CONTROL_SEGMENT = 'shrink-0 rounded-sm border-0 bg-transparent p-0 text-center text-sm text-fg tabular-nums ' +
102
+ 'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
103
+ 'disabled:cursor-not-allowed';
@@ -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;