@eifi1/ui-kit 0.6.1 → 0.7.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 +69 -33
- package/dist/chart.d.ts +1 -0
- package/dist/components/account-settings.js +3 -2
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/amount-input.js +6 -4
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +33 -6
- package/dist/components/autocomplete.js +62 -16
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/calculator.js +2 -1
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.js +2 -1
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chart.d.ts +33 -2
- package/dist/components/chart.js +20 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +34 -14
- package/dist/components/chip.js +63 -28
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +18 -1
- package/dist/components/choice-card.js +21 -8
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.d.ts +61 -2
- package/dist/components/combobox-core.js +68 -17
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +11 -7
- package/dist/components/combobox.js +51 -28
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.js +3 -3
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +30 -6
- package/dist/components/danger-confirm.js +51 -32
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +6 -3
- package/dist/components/data-table-pagination.js +6 -5
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +332 -248
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +17 -7
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +28 -3
- package/dist/components/dialog-frame.js +47 -11
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +22 -2
- package/dist/components/disclosure.js +41 -8
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.d.ts +7 -3
- package/dist/components/dropdown.js +6 -2
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +17 -8
- package/dist/components/entity-combobox.js +7 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +36 -5
- package/dist/components/file-button.js +23 -2
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +9 -5
- package/dist/components/file-dropzone.js +14 -19
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/grouped-picker.js +2 -2
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +8 -3
- package/dist/components/hover-menu.js +88 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-picker.d.ts +10 -1
- package/dist/components/icon-picker.js +5 -1
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/measured-grid.d.ts +165 -0
- package/dist/components/measured-grid.js +559 -0
- package/dist/components/measured-grid.js.map +1 -0
- package/dist/components/mini-calendar.js +7 -8
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/month-picker.js +13 -9
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +2 -2
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +9 -1
- package/dist/components/multi-select.js +22 -8
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +3 -0
- package/dist/components/number-input.d.ts +3 -0
- package/dist/components/number-input.js +3 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/numpad-sheet.js +3 -3
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/picker-sheet.js +1 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.js +11 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/series-chart-ticks.d.ts +13 -0
- package/dist/components/series-chart-ticks.js +29 -0
- package/dist/components/series-chart-ticks.js.map +1 -0
- package/dist/components/series-chart.js +33 -27
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +10 -1
- package/dist/components/swatch-picker.js +2 -0
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +11 -2
- package/dist/components/swipeable-row.js +5 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tile-radio.js +5 -7
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/toggle-group.d.ts +13 -3
- package/dist/components/toggle-group.js +21 -1
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.js +1 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/treemap.d.ts +6 -1
- package/dist/components/treemap.js +16 -6
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +31 -7
- package/dist/components/ui.js +26 -18
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-anchor-dir.d.ts +15 -0
- package/dist/components/use-anchor-dir.js +14 -0
- package/dist/components/use-anchor-dir.js.map +1 -0
- package/dist/components/use-mobile-reveal.d.ts +8 -1
- package/dist/components/use-mobile-reveal.js +3 -2
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +4 -4
- package/dist/components/use-table-state.js +12 -5
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +22 -3
- package/dist/feedback/feedback-attachment.js +15 -7
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +3 -2
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-windowed-rows.d.ts +29 -0
- package/dist/hooks/use-windowed-rows.js +31 -0
- package/dist/hooks/use-windowed-rows.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +43 -0
- package/dist/i18n/locales/de-CH.js +21 -0
- package/dist/i18n/locales/de-CH.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +43 -0
- package/dist/i18n/locales/de.js +344 -0
- package/dist/i18n/locales/de.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +43 -0
- package/dist/i18n/locales/es.js +339 -0
- package/dist/i18n/locales/es.js.map +1 -0
- package/dist/i18n/locales/fr.d.ts +43 -0
- package/dist/i18n/locales/fr.js +344 -0
- package/dist/i18n/locales/fr.js.map +1 -0
- package/dist/i18n/locales/hu.d.ts +43 -0
- package/dist/i18n/locales/hu.js +343 -0
- package/dist/i18n/locales/hu.js.map +1 -0
- package/dist/i18n/locales/it.d.ts +43 -0
- package/dist/i18n/locales/it.js +342 -0
- package/dist/i18n/locales/it.js.map +1 -0
- package/dist/i18n/locales/zh.d.ts +43 -0
- package/dist/i18n/locales/zh.js +338 -0
- package/dist/i18n/locales/zh.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/direction.d.ts +24 -0
- package/dist/lib/direction.js +19 -0
- package/dist/lib/direction.js.map +1 -0
- package/dist/rhf/form.js.map +1 -1
- package/dist/search/command-palette.d.ts +5 -0
- package/dist/search/command-palette.js +80 -50
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +13 -1
- package/dist/shell/app-shell.js +133 -84
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +1 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +7 -3
- package/dist/shell/topbar-controls.js +38 -31
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/theme/chart-palette.d.ts +7 -1
- package/dist/theme/chart-palette.js +2 -1
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/tour/tour.d.ts +11 -5
- package/dist/tour/tour.js +124 -58
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/use-wizard.js +30 -13
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.js +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +12 -2
- package/src/components/account-settings.tsx +6 -2
- package/src/components/amount-input.tsx +10 -8
- package/src/components/autocomplete.tsx +110 -24
- package/src/components/calculator.tsx +5 -1
- package/src/components/chart-zoom.tsx +8 -1
- package/src/components/chart.tsx +68 -7
- package/src/components/chip.tsx +131 -49
- package/src/components/choice-card.tsx +77 -36
- package/src/components/combobox-core.tsx +147 -20
- package/src/components/combobox.tsx +100 -44
- package/src/components/currency-select.tsx +6 -6
- package/src/components/danger-confirm.tsx +90 -36
- package/src/components/data-table-pagination.tsx +32 -19
- package/src/components/data-table.tsx +198 -36
- package/src/components/date-picker.tsx +24 -7
- package/src/components/dialog-frame.tsx +97 -22
- package/src/components/disclosure.tsx +75 -13
- package/src/components/dropdown.tsx +17 -5
- package/src/components/entity-combobox.tsx +26 -13
- package/src/components/file-button.tsx +74 -5
- package/src/components/file-dropzone.tsx +44 -35
- package/src/components/grouped-picker.tsx +2 -2
- package/src/components/hover-menu.tsx +139 -10
- package/src/components/icon-picker.tsx +14 -1
- package/src/components/measured-grid.tsx +852 -0
- package/src/components/mini-calendar.tsx +11 -8
- package/src/components/month-picker.tsx +18 -11
- package/src/components/multi-entity-combobox.tsx +2 -2
- package/src/components/multi-select.tsx +34 -7
- package/src/components/number-input.tsx +4 -4
- package/src/components/numpad-sheet.tsx +5 -3
- package/src/components/picker-sheet.tsx +1 -1
- package/src/components/popover.tsx +18 -3
- package/src/components/series-chart-ticks.ts +52 -0
- package/src/components/series-chart.tsx +39 -31
- package/src/components/swatch-picker.tsx +11 -0
- package/src/components/swipeable-row.tsx +21 -6
- package/src/components/tile-radio.tsx +5 -7
- package/src/components/toggle-group.tsx +39 -3
- package/src/components/toggle-legend.tsx +5 -2
- package/src/components/treemap.tsx +28 -5
- package/src/components/ui.tsx +74 -30
- package/src/components/use-anchor-dir.ts +20 -0
- package/src/components/use-mobile-reveal.ts +10 -2
- package/src/components/use-table-state.ts +44 -7
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-attachment.tsx +36 -9
- package/src/feedback/feedback-dialog.tsx +3 -2
- package/src/hooks/use-windowed-rows.ts +69 -0
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/de-CH.ts +30 -0
- package/src/i18n/locales/de.ts +365 -0
- package/src/i18n/locales/es.ts +358 -0
- package/src/i18n/locales/fr.ts +371 -0
- package/src/i18n/locales/hu.ts +359 -0
- package/src/i18n/locales/it.ts +360 -0
- package/src/i18n/locales/zh.ts +348 -0
- package/src/index.ts +10 -1
- package/src/lib/direction.ts +37 -0
- package/src/rhf/form.tsx +21 -0
- package/src/search/command-palette.tsx +77 -19
- package/src/shell/app-shell.tsx +200 -129
- package/src/shell/topbar-action-menu.tsx +1 -1
- package/src/shell/topbar-controls.tsx +19 -8
- package/src/theme/chart-palette.ts +9 -2
- package/src/tour/tour.tsx +119 -18
- package/src/wizard/use-wizard.ts +55 -17
- package/src/wizard/wizard-summary.tsx +1 -1
package/src/components/chip.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactNode } f
|
|
|
3
3
|
import { X } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
+
import { horizontalStep } from "../lib/direction";
|
|
6
7
|
import { FIELD_INVALID } from "./ui";
|
|
7
8
|
import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
8
9
|
|
|
@@ -72,20 +73,48 @@ const TONE: Record<ChipTone, { idle: string; selected: string }> = {
|
|
|
72
73
|
},
|
|
73
74
|
};
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
/**
|
|
77
|
+
* `body` is the whole pill when the chip is ONE element. When a link or a button also has
|
|
78
|
+
* a dismiss control, the pill is a wrapper holding two siblings (see the note on
|
|
79
|
+
* {@link Chip}): `split` is then the interactive part's own padding — the full start
|
|
80
|
+
* inset, a short end one before the ×; `tail` is the wrapper's end inset after the ×.
|
|
81
|
+
*/
|
|
82
|
+
const SIZE: Record<
|
|
83
|
+
ChipSize,
|
|
84
|
+
{ body: string; split: string; tail: string; icon: string; remove: string }
|
|
85
|
+
> = {
|
|
86
|
+
sm: {
|
|
87
|
+
body: "gap-1 px-2 py-0.5 text-xs",
|
|
88
|
+
split: "gap-1 ps-2 pe-1 py-0.5 text-xs",
|
|
89
|
+
tail: "pe-1.5",
|
|
90
|
+
icon: "size-3",
|
|
91
|
+
remove: "size-3",
|
|
92
|
+
},
|
|
93
|
+
md: {
|
|
94
|
+
body: "gap-1.5 px-2.5 py-1 text-sm",
|
|
95
|
+
split: "gap-1.5 ps-2.5 pe-1.5 py-1 text-sm",
|
|
96
|
+
tail: "pe-2",
|
|
97
|
+
icon: "size-3.5",
|
|
98
|
+
remove: "size-3.5",
|
|
99
|
+
},
|
|
100
|
+
lg: {
|
|
101
|
+
body: "min-h-11 gap-2 px-4 py-2 text-sm",
|
|
102
|
+
split: "min-h-11 gap-2 ps-4 pe-2 py-2 text-sm",
|
|
103
|
+
tail: "min-h-11 pe-3.5",
|
|
104
|
+
icon: "size-4",
|
|
105
|
+
remove: "size-4",
|
|
106
|
+
},
|
|
79
107
|
};
|
|
80
108
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
109
|
+
const CHIP_PILL = "inline-flex max-w-full items-center rounded-full border transition-colors";
|
|
110
|
+
// `focus-visible`, not `focus`: a chip commonly receives focus programmatically (the
|
|
111
|
+
// ChipInput moves focus onto one after a removal) and a ring that appears on a
|
|
112
|
+
// pointer click reads as a stuck selection.
|
|
113
|
+
const CHIP_RING =
|
|
86
114
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-surface)]";
|
|
115
|
+
const CHIP_BASE = `${CHIP_PILL} ${CHIP_RING}`;
|
|
87
116
|
|
|
88
|
-
|
|
117
|
+
interface ChipBaseProps {
|
|
89
118
|
children: ReactNode;
|
|
90
119
|
tone?: ChipTone;
|
|
91
120
|
size?: ChipSize;
|
|
@@ -94,16 +123,19 @@ export interface ChipProps {
|
|
|
94
123
|
* Marks the chip as the current one — `aria-current` on a link, `aria-pressed` on a
|
|
95
124
|
* toggle.
|
|
96
125
|
*
|
|
97
|
-
* On
|
|
98
|
-
* state. A label that flips with it ("Skip this month" / "Ask again this
|
|
99
|
-
* announced together with "pressed", and then says the opposite of what
|
|
126
|
+
* On an ON/OFF toggle, keep the LABEL the same in both states and let `selected`
|
|
127
|
+
* carry the state. A label that flips with it ("Skip this month" / "Ask again this
|
|
128
|
+
* month") is announced together with "pressed", and then says the opposite of what
|
|
129
|
+
* it does.
|
|
130
|
+
*
|
|
131
|
+
* A control that switches between two NAMED states (outflow ⇄ inflow) is not a
|
|
132
|
+
* toggle but an ACTION button: leave `selected` out, let the visible label and the
|
|
133
|
+
* tone follow the state ("− Outflow" / "+ Inflow"), and name the action in
|
|
134
|
+
* `aria-label` ("Direction: outflow — tap for inflow"). Without `selected` the chip
|
|
135
|
+
* reports no pressed state at all. A fixed label there contradicts the figure beside
|
|
136
|
+
* it — "+ Outflow" in green next to an inflow (keksdose #417).
|
|
100
137
|
*/
|
|
101
138
|
selected?: boolean;
|
|
102
|
-
/** Renders the chip as a link. Mutually exclusive with `onClick`. */
|
|
103
|
-
href?: string;
|
|
104
|
-
/** Renders the chip as a toggle button. Mutually exclusive with `href`. Receives the
|
|
105
|
-
* click, so a chip inside a clickable row can `stopPropagation()`. */
|
|
106
|
-
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
107
139
|
/** Renders a dismiss affordance. Works alongside `href`/`onClick` — see the note below. */
|
|
108
140
|
onRemove?: () => void;
|
|
109
141
|
/** Accessible name for the dismiss button. Default: `common.remove` from the
|
|
@@ -132,12 +164,34 @@ export interface ChipProps {
|
|
|
132
164
|
title?: string;
|
|
133
165
|
}
|
|
134
166
|
|
|
167
|
+
/**
|
|
168
|
+
* A chip is a link OR a button, never both — so the types say so. Before 0.7.0 both were
|
|
169
|
+
* accepted and `onClick` was silently dropped whenever `href` was set; a link that must
|
|
170
|
+
* also run code wants a router `Link`-style `onClick` on the anchor, which is a different
|
|
171
|
+
* component's job.
|
|
172
|
+
*/
|
|
173
|
+
export type ChipProps = ChipBaseProps &
|
|
174
|
+
(
|
|
175
|
+
| {
|
|
176
|
+
/** Renders the chip as a link. Mutually exclusive with `onClick`. */
|
|
177
|
+
href?: string;
|
|
178
|
+
onClick?: never;
|
|
179
|
+
}
|
|
180
|
+
| {
|
|
181
|
+
href?: never;
|
|
182
|
+
/** Renders the chip as a toggle button. Mutually exclusive with `href`. Receives
|
|
183
|
+
* the click, so a chip inside a clickable row can `stopPropagation()`. */
|
|
184
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
135
188
|
/**
|
|
136
189
|
* Note on `onRemove` with `href`/`onClick`: a button cannot be nested inside a button or
|
|
137
190
|
* a link, so when both are present the chip renders a wrapper holding TWO siblings — the
|
|
138
|
-
* interactive body and the dismiss control.
|
|
139
|
-
* the
|
|
140
|
-
*
|
|
191
|
+
* interactive body and the dismiss control. The WRAPPER is the visual pill (border, tone,
|
|
192
|
+
* `className`) and the two sit inside it, so the × reads as part of the chip; the body's
|
|
193
|
+
* focus ring is drawn on the body alone, and `ref`, `id` and the ARIA props still reach
|
|
194
|
+
* the body — the interactive element.
|
|
141
195
|
*/
|
|
142
196
|
export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
143
197
|
{
|
|
@@ -145,7 +199,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
145
199
|
tone = "neutral",
|
|
146
200
|
size = "md",
|
|
147
201
|
icon: Icon,
|
|
148
|
-
selected
|
|
202
|
+
selected,
|
|
149
203
|
href,
|
|
150
204
|
onClick,
|
|
151
205
|
onRemove,
|
|
@@ -164,12 +218,15 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
164
218
|
const s = SIZE[size];
|
|
165
219
|
const palette = TONE[tone];
|
|
166
220
|
const interactive = !!href || !!onClick;
|
|
221
|
+
const surface = cn(
|
|
222
|
+
selected ? palette.selected : palette.idle,
|
|
223
|
+
disabled && "cursor-default opacity-50",
|
|
224
|
+
);
|
|
167
225
|
const look = cn(
|
|
168
226
|
CHIP_BASE,
|
|
169
227
|
s.body,
|
|
170
|
-
|
|
228
|
+
surface,
|
|
171
229
|
interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
|
|
172
|
-
disabled && "cursor-default opacity-50",
|
|
173
230
|
className,
|
|
174
231
|
);
|
|
175
232
|
|
|
@@ -202,7 +259,8 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
202
259
|
}}
|
|
203
260
|
disabled={disabled}
|
|
204
261
|
className={cn(
|
|
205
|
-
|
|
262
|
+
// Logical margins: the × sits at the END of the pill, which is the left in RTL.
|
|
263
|
+
"-me-0.5 ms-0.5 shrink-0 rounded-full p-0.5 transition-colors",
|
|
206
264
|
"hover:bg-[var(--bg-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
|
|
207
265
|
disabled && "pointer-events-none",
|
|
208
266
|
)}
|
|
@@ -211,6 +269,23 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
211
269
|
</button>
|
|
212
270
|
) : null;
|
|
213
271
|
|
|
272
|
+
// ── The combined shape: one pill, two interactive siblings inside it.
|
|
273
|
+
const pill = (inner: ReactNode) => (
|
|
274
|
+
<span className={cn(CHIP_PILL, s.tail, surface, className)}>
|
|
275
|
+
{inner}
|
|
276
|
+
{remove}
|
|
277
|
+
</span>
|
|
278
|
+
);
|
|
279
|
+
// The body inside a pill: no border or surface of its own (the pill has them), a
|
|
280
|
+
// hover wash in the text's own hue so it works on every tone.
|
|
281
|
+
const inner = cn(
|
|
282
|
+
"inline-flex min-w-0 items-center self-stretch rounded-full transition-colors",
|
|
283
|
+
CHIP_RING,
|
|
284
|
+
s.split,
|
|
285
|
+
!disabled && "cursor-pointer hover:bg-current/10",
|
|
286
|
+
disabled && "cursor-default",
|
|
287
|
+
);
|
|
288
|
+
|
|
214
289
|
// ── The three shapes.
|
|
215
290
|
if (href && !disabled) {
|
|
216
291
|
const link = (
|
|
@@ -218,21 +293,13 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
218
293
|
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
219
294
|
href={href}
|
|
220
295
|
aria-current={selected ? "true" : undefined}
|
|
221
|
-
className={
|
|
296
|
+
className={remove ? inner : look}
|
|
222
297
|
{...rest}
|
|
223
298
|
>
|
|
224
299
|
{body}
|
|
225
|
-
{!remove && null}
|
|
226
300
|
</a>
|
|
227
301
|
);
|
|
228
|
-
return remove ? (
|
|
229
|
-
<span className="inline-flex items-center">
|
|
230
|
-
{link}
|
|
231
|
-
{remove}
|
|
232
|
-
</span>
|
|
233
|
-
) : (
|
|
234
|
-
link
|
|
235
|
-
);
|
|
302
|
+
return remove ? pill(link) : link;
|
|
236
303
|
}
|
|
237
304
|
|
|
238
305
|
if (onClick) {
|
|
@@ -242,21 +309,17 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
242
309
|
type="button"
|
|
243
310
|
onClick={onClick}
|
|
244
311
|
disabled={disabled}
|
|
312
|
+
// Only when `selected` is PASSED: a chip with `onClick` and no `selected` is an
|
|
313
|
+
// action button (it does something), not a toggle (it is on or off), and
|
|
314
|
+
// announcing it "not pressed" would claim a state it does not have.
|
|
245
315
|
aria-pressed={selected}
|
|
246
|
-
className={
|
|
316
|
+
className={remove ? inner : look}
|
|
247
317
|
{...rest}
|
|
248
318
|
>
|
|
249
319
|
{body}
|
|
250
320
|
</button>
|
|
251
321
|
);
|
|
252
|
-
return remove ? (
|
|
253
|
-
<span className="inline-flex items-center">
|
|
254
|
-
{button}
|
|
255
|
-
{remove}
|
|
256
|
-
</span>
|
|
257
|
-
) : (
|
|
258
|
-
button
|
|
259
|
-
);
|
|
322
|
+
return remove ? pill(button) : button;
|
|
260
323
|
}
|
|
261
324
|
|
|
262
325
|
return (
|
|
@@ -344,7 +407,7 @@ export interface ChipInputProps
|
|
|
344
407
|
*
|
|
345
408
|
* Enter, or any separator commit the typed text
|
|
346
409
|
* Backspace on empty text move focus to the last chip (does NOT delete it)
|
|
347
|
-
* ← → while on a chip move between chips
|
|
410
|
+
* ← → while on a chip move between chips, in reading order (mirrored in RTL)
|
|
348
411
|
* Backspace / Delete remove the focused chip, focus its neighbour
|
|
349
412
|
* Escape clear what is typed but keep the committed chips
|
|
350
413
|
* paste splits on the separators, so a pasted CSV becomes chips
|
|
@@ -384,13 +447,21 @@ export function ChipInput({
|
|
|
384
447
|
const locale = useKitLocale();
|
|
385
448
|
const [draft, setDraft] = useState("");
|
|
386
449
|
const [message, setMessage] = useState("");
|
|
450
|
+
// The last refusal, shown under the field until the next edit. The live region alone
|
|
451
|
+
// told only a screen-reader user why Enter did nothing; everyone else saw the text
|
|
452
|
+
// simply stay put.
|
|
453
|
+
const [rejected, setRejected] = useState<string | null>(null);
|
|
387
454
|
const [focusedChip, setFocusedChip] = useState<number | null>(null);
|
|
388
455
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
389
456
|
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
|
390
457
|
const id = useId();
|
|
391
458
|
const listId = `${id}-list`;
|
|
392
459
|
const errorId = `${id}-error`;
|
|
460
|
+
const rejectedId = `${id}-rejected`;
|
|
393
461
|
const isInvalid = invalid || !!error;
|
|
462
|
+
const describedBy = [error ? errorId : null, rejected ? rejectedId : null]
|
|
463
|
+
.filter(Boolean)
|
|
464
|
+
.join(" ");
|
|
394
465
|
|
|
395
466
|
const focusChip = (index: number | null) => {
|
|
396
467
|
setFocusedChip(index);
|
|
@@ -437,6 +508,7 @@ export function ChipInput({
|
|
|
437
508
|
// chips appearing, whereas "already in the list" is the only sign of what did not.
|
|
438
509
|
const addedList = new Intl.ListFormat(locale, { type: "conjunction", style: "long" });
|
|
439
510
|
setMessage(rejection ?? (added.length ? text.added(addedList.format(added)) : ""));
|
|
511
|
+
setRejected(rejection);
|
|
440
512
|
return { added, rejection };
|
|
441
513
|
};
|
|
442
514
|
|
|
@@ -446,6 +518,7 @@ export function ChipInput({
|
|
|
446
518
|
const removed = value[index];
|
|
447
519
|
onChange(value.filter((_, i) => i !== index));
|
|
448
520
|
setMessage(text.removed(removed));
|
|
521
|
+
setRejected(null);
|
|
449
522
|
// Focus the neighbour that takes its place, or the input when the list empties —
|
|
450
523
|
// dropping focus to <body> here is how a keyboard user loses the field entirely.
|
|
451
524
|
const next = index >= value.length - 1 ? null : index;
|
|
@@ -483,12 +556,15 @@ export function ChipInput({
|
|
|
483
556
|
removeAt(index);
|
|
484
557
|
return;
|
|
485
558
|
}
|
|
486
|
-
|
|
559
|
+
// ←/→ follow the reading direction, not DOM order: in RTL the previous chip is to
|
|
560
|
+
// the RIGHT, and ArrowLeft moves on towards the input.
|
|
561
|
+
const step = horizontalStep(e.key, e.currentTarget);
|
|
562
|
+
if (step === -1 && index > 0) {
|
|
487
563
|
e.preventDefault();
|
|
488
564
|
focusChip(index - 1);
|
|
489
565
|
return;
|
|
490
566
|
}
|
|
491
|
-
if (
|
|
567
|
+
if (step === 1) {
|
|
492
568
|
e.preventDefault();
|
|
493
569
|
focusChip(index === value.length - 1 ? null : index + 1);
|
|
494
570
|
return;
|
|
@@ -564,6 +640,7 @@ export function ChipInput({
|
|
|
564
640
|
onChange={(e) => {
|
|
565
641
|
setDraft(e.target.value);
|
|
566
642
|
setMessage("");
|
|
643
|
+
setRejected(null);
|
|
567
644
|
}}
|
|
568
645
|
onKeyDown={onInputKeyDown}
|
|
569
646
|
onBlur={() => {
|
|
@@ -585,8 +662,8 @@ export function ChipInput({
|
|
|
585
662
|
// here would give the field two. Failing that, the DOM spelling wins and
|
|
586
663
|
// the deprecated `ariaLabel` is the fallback.
|
|
587
664
|
aria-label={label ? undefined : (ariaLabelAttr ?? ariaLabel)}
|
|
588
|
-
aria-describedby={
|
|
589
|
-
aria-invalid={isInvalid || undefined}
|
|
665
|
+
aria-describedby={describedBy || undefined}
|
|
666
|
+
aria-invalid={isInvalid || !!rejected || undefined}
|
|
590
667
|
className="min-w-[6rem] flex-1 bg-transparent py-0.5 text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-placeholder)] disabled:cursor-default"
|
|
591
668
|
/>
|
|
592
669
|
{/* Additions, removals and rejections move no focus, so nothing would announce
|
|
@@ -601,6 +678,11 @@ export function ChipInput({
|
|
|
601
678
|
{error}
|
|
602
679
|
</p>
|
|
603
680
|
)}
|
|
681
|
+
{rejected && (
|
|
682
|
+
<p id={rejectedId} className="text-xs text-[var(--danger)]" data-chip-input-rejected="">
|
|
683
|
+
{rejected}
|
|
684
|
+
</p>
|
|
685
|
+
)}
|
|
604
686
|
</div>
|
|
605
687
|
);
|
|
606
688
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, useCallback, useEffect, useId, useRef } from "react";
|
|
1
|
+
import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useRef } from "react";
|
|
2
2
|
import type { ChangeEvent, ComponentType, InputHTMLAttributes, ReactNode } from "react";
|
|
3
3
|
import { Check, Minus } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
@@ -48,6 +48,29 @@ export interface ChoiceCardProps extends Omit<InputHTMLAttributes<HTMLInputEleme
|
|
|
48
48
|
onCheckedChange?: (checked: boolean) => void;
|
|
49
49
|
/** Classes for the `<input>`. `className` styles the card. */
|
|
50
50
|
inputClassName?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Must be ticked to submit. Reaches the `<input>` as the native `required` (a
|
|
53
|
+
* `<form>` refuses to submit without it, a screen reader announces "required"),
|
|
54
|
+
* and draws the kit's `aria-hidden` star after the title — {@link Checkbox}'s
|
|
55
|
+
* contract, down to writing the title WITHOUT a literal "*". Inside a
|
|
56
|
+
* {@link ChoiceCardGroup} the star goes on the legend instead, once.
|
|
57
|
+
*/
|
|
58
|
+
required?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Set by {@link ChoiceCardGroup}: its legend carries the one required mark, so the
|
|
62
|
+
* cards inside it draw none of their own. Private — not a prop a caller could set
|
|
63
|
+
* by accident on a lone card. */
|
|
64
|
+
const InGroupContext = createContext(false);
|
|
65
|
+
|
|
66
|
+
/** The kit's required mark — {@link Label}'s: `aria-hidden`, because the word is
|
|
67
|
+
* announced from the control and a star read out in the name is only noise. */
|
|
68
|
+
function RequiredMark() {
|
|
69
|
+
return (
|
|
70
|
+
<span aria-hidden className="ms-0.5 text-[var(--danger)]">
|
|
71
|
+
*
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
// The card. `has-[:checked]` and `has-[:focus-visible]` read the INPUT's state, so an
|
|
@@ -93,10 +116,12 @@ export const ChoiceCard = forwardRef<HTMLInputElement, ChoiceCardProps>(function
|
|
|
93
116
|
inputClassName,
|
|
94
117
|
id,
|
|
95
118
|
disabled,
|
|
119
|
+
required,
|
|
96
120
|
...rest
|
|
97
121
|
},
|
|
98
122
|
ref,
|
|
99
123
|
) {
|
|
124
|
+
const inGroup = useContext(InGroupContext);
|
|
100
125
|
const generated = useId();
|
|
101
126
|
const inputId = id ?? generated;
|
|
102
127
|
const titleId = `${inputId}-title`;
|
|
@@ -131,6 +156,7 @@ export const ChoiceCard = forwardRef<HTMLInputElement, ChoiceCardProps>(function
|
|
|
131
156
|
ref={setRef}
|
|
132
157
|
id={inputId}
|
|
133
158
|
disabled={disabled}
|
|
159
|
+
required={required}
|
|
134
160
|
{...rest}
|
|
135
161
|
type={type}
|
|
136
162
|
aria-labelledby={rest["aria-labelledby"] ?? titleId}
|
|
@@ -174,6 +200,7 @@ export const ChoiceCard = forwardRef<HTMLInputElement, ChoiceCardProps>(function
|
|
|
174
200
|
className="block select-none text-sm font-medium leading-5 text-[var(--text-primary)]"
|
|
175
201
|
>
|
|
176
202
|
{title}
|
|
203
|
+
{required && !inGroup && <RequiredMark />}
|
|
177
204
|
</span>
|
|
178
205
|
{showDescription && (
|
|
179
206
|
<span id={descriptionId} className="mt-0.5 block text-xs text-[var(--text-muted)]">
|
|
@@ -217,7 +244,16 @@ interface ChoiceCardGroupBaseProps<T extends string> {
|
|
|
217
244
|
/** What is wrong with the answer as a whole. Rendered under the cards and attached
|
|
218
245
|
* to the fieldset. */
|
|
219
246
|
error?: ReactNode;
|
|
220
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* An answer is needed. Draws the required star after the `legend` (none without
|
|
249
|
+
* one — the name then comes from `aria-label`, which has nowhere to draw it), and
|
|
250
|
+
* reaches the inputs as the native `required`:
|
|
251
|
+
* - radios: every radio of the set, which is how HTML marks a radio group — the
|
|
252
|
+
* group is satisfied by any one of them, and each announces "required";
|
|
253
|
+
* - checkboxes: every box while NONE is ticked, dropped from all of them once one
|
|
254
|
+
* is — "at least one", which checkboxes cannot say natively. A form then refuses
|
|
255
|
+
* to submit an empty set, and nothing is left demanding a second box.
|
|
256
|
+
*/
|
|
221
257
|
required?: boolean;
|
|
222
258
|
}
|
|
223
259
|
|
|
@@ -267,39 +303,44 @@ export function ChoiceCardGroup<T extends string>(props: ChoiceCardGroupProps<T>
|
|
|
267
303
|
};
|
|
268
304
|
|
|
269
305
|
return (
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
</
|
|
302
|
-
|
|
303
|
-
|
|
306
|
+
<InGroupContext.Provider value={true}>
|
|
307
|
+
<fieldset
|
|
308
|
+
aria-label={props["aria-label"]}
|
|
309
|
+
aria-describedby={showError ? errorId : undefined}
|
|
310
|
+
disabled={disabled}
|
|
311
|
+
className="min-w-0"
|
|
312
|
+
>
|
|
313
|
+
{legend !== undefined && (
|
|
314
|
+
<legend className="mb-2 text-sm font-medium text-[var(--text-primary)]">
|
|
315
|
+
{legend}
|
|
316
|
+
{required && <RequiredMark />}
|
|
317
|
+
</legend>
|
|
318
|
+
)}
|
|
319
|
+
<div className={cn("grid gap-2 sm:grid-cols-2", className)}>
|
|
320
|
+
{options.map((o) => (
|
|
321
|
+
<ChoiceCard
|
|
322
|
+
key={o.value}
|
|
323
|
+
type={props.multiple ? "checkbox" : "radio"}
|
|
324
|
+
name={groupName}
|
|
325
|
+
value={o.value}
|
|
326
|
+
title={o.title}
|
|
327
|
+
description={o.description}
|
|
328
|
+
icon={o.icon}
|
|
329
|
+
disabled={o.disabled}
|
|
330
|
+
invalid={showError}
|
|
331
|
+
required={required && (!props.multiple || props.value.length === 0) ? true : undefined}
|
|
332
|
+
checked={isChecked(o.value)}
|
|
333
|
+
onCheckedChange={(on) => toggle(o.value, on)}
|
|
334
|
+
className={cardClassName}
|
|
335
|
+
/>
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
{showError && (
|
|
339
|
+
<p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
|
|
340
|
+
{error}
|
|
341
|
+
</p>
|
|
342
|
+
)}
|
|
343
|
+
</fieldset>
|
|
344
|
+
</InGroupContext.Provider>
|
|
304
345
|
);
|
|
305
346
|
}
|