@eifi1/ui-kit 0.17.0 → 0.19.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 +83 -4
- package/dist/chart.d.ts +13 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -1
- package/dist/components/button-group.d.ts +13 -1
- package/dist/components/calculator.d.ts +13 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +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.d.ts +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +13 -1
- package/dist/components/file-button.d.ts +13 -1
- package/dist/components/file-dropzone.d.ts +13 -1
- package/dist/components/floating-panel.d.ts +4 -1
- package/dist/components/floating-panel.js +15 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +13 -1
- package/dist/components/form-actions.js +3 -0
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/legal.d.ts +84 -0
- package/dist/components/legal.js +55 -0
- package/dist/components/legal.js.map +1 -0
- package/dist/components/money-field.d.ts +13 -1
- package/dist/components/number-field.d.ts +14 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +13 -1
- package/dist/components/numpad-sheet.d.ts +13 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +15 -1
- package/dist/components/progress-bar.js +8 -1
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +13 -1
- package/dist/components/server-wake.d.ts +60 -0
- package/dist/components/server-wake.js +79 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +13 -1
- package/dist/components/share-card.d.ts +64 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +13 -1
- package/dist/components/time-input.d.ts +13 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/translation-review-editor.d.ts +56 -0
- package/dist/components/translation-review-editor.js +159 -0
- package/dist/components/translation-review-editor.js.map +1 -0
- package/dist/components/translation-review-labels.d.ts +92 -0
- package/dist/components/translation-review-labels.js +67 -0
- package/dist/components/translation-review-labels.js.map +1 -0
- package/dist/components/translation-review-parts.d.ts +97 -0
- package/dist/components/translation-review-parts.js +163 -0
- package/dist/components/translation-review-parts.js.map +1 -0
- package/dist/components/translation-review.d.ts +94 -0
- package/dist/components/translation-review.js +344 -0
- package/dist/components/translation-review.js.map +1 -0
- package/dist/components/ui.d.ts +13 -1
- package/dist/components/ui.js +43 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +13 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +13 -1
- package/dist/i18n/defaults.js +15 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +81 -0
- package/dist/i18n/{locales/de.js → german.js} +110 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +13 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +243 -0
- package/dist/i18n/languages.js +70 -0
- package/dist/i18n/languages.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +26 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/en.d.ts +13 -1
- package/dist/i18n/locales/en.js +8 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +13 -1
- package/dist/i18n/locales/es.js +127 -27
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +13 -1
- package/dist/i18n/locales/fr.js +101 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +13 -1
- package/dist/i18n/locales/hu.js +105 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +13 -1
- package/dist/i18n/locales/it.js +134 -34
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +13 -1
- package/dist/i18n/locales/zh.js +101 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +102 -0
- package/dist/i18n/review.js +158 -0
- package/dist/i18n/review.js.map +1 -0
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +97 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
- package/dist/lib/floating-stack.d.ts +31 -0
- package/dist/lib/floating-stack.js +42 -0
- package/dist/lib/floating-stack.js.map +1 -0
- package/dist/lib/server-wake.d.ts +146 -0
- package/dist/lib/server-wake.js +122 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/lib/translation-review.d.ts +232 -0
- package/dist/lib/translation-review.js +194 -0
- package/dist/lib/translation-review.js.map +1 -0
- package/dist/rhf/fields.d.ts +13 -1
- package/dist/rhf/form.d.ts +13 -1
- package/dist/rhf.d.ts +13 -1
- package/dist/search/global-search.d.ts +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/shell/app-shell.d.ts +13 -1
- package/dist/shell/top-bar-brand.d.ts +13 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +13 -1
- package/dist/wizard/stepper-nav.d.ts +13 -1
- package/dist/wizard.d.ts +13 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +20 -0
- package/src/components/floating-panel.tsx +19 -1
- package/src/components/form-actions.tsx +22 -3
- package/src/components/legal.tsx +159 -0
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +21 -1
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +156 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/translation-review-editor.tsx +261 -0
- package/src/components/translation-review-labels.ts +152 -0
- package/src/components/translation-review-parts.tsx +269 -0
- package/src/components/translation-review.tsx +502 -0
- package/src/components/ui.tsx +89 -32
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +14 -0
- package/src/i18n/{locales/de.ts → german.ts} +120 -13
- package/src/i18n/kit-labels.tsx +29 -2
- package/src/i18n/languages.ts +285 -0
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +8 -0
- package/src/i18n/locales/es.ts +128 -26
- package/src/i18n/locales/fr.ts +103 -0
- package/src/i18n/locales/hu.ts +107 -0
- package/src/i18n/locales/it.ts +135 -33
- package/src/i18n/locales/zh.ts +102 -0
- package/src/i18n/review.ts +193 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +86 -1
- package/src/lib/floating-stack.ts +66 -0
- package/src/lib/server-wake.ts +291 -0
- package/src/lib/translation-review.ts +465 -0
- package/src/search/global-search.tsx +3 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.d.ts +0 -66
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { LucideIcon } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { formatDate, formatRelativeTime, toDate, EMPTY_FORMATTED_VALUE } from "../lib/format";
|
|
5
|
+
import type { DateInput, FormatDateStyle, FormatRelativeTimeOptions } from "../lib/format";
|
|
6
|
+
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
7
|
+
import { Chip } from "./chip";
|
|
8
|
+
import type { ChipShape, ChipSize, ChipTone, ChipVariant } from "./chip";
|
|
9
|
+
import { Tooltip } from "./tooltip";
|
|
10
|
+
import type { DataTableColumn } from "./data-table";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The parts an admin roster repeats (0.18.0) — and only those.
|
|
14
|
+
*
|
|
15
|
+
* Kurvenschmiede (features/admin/users-panel.tsx, allowlist-panel.tsx) and keksdose
|
|
16
|
+
* (features/admin/users-panel.tsx, the allowlist card in admin-page.tsx) each built the
|
|
17
|
+
* same table: name over email, a role, whether the account is active, created, last
|
|
18
|
+
* login, a row of actions, and an allowlist with a note. What they share is not the
|
|
19
|
+
* table — the columns around it (keksdose's plan and privacy mode, Kurvenschmiede's
|
|
20
|
+
* owned-work counts and its role SELECT), the actions and every rule about who may do
|
|
21
|
+
* what are each app's own. What they share is the vocabulary: both spelled the same
|
|
22
|
+
* square, uppercase status chip by hand (keksdose's `Badge` with its own tone map,
|
|
23
|
+
* Kurvenschmiede's inline `<Chip size="xs" shape="square" caps>`), and both formatted
|
|
24
|
+
* their two date columns by hand (`toLocaleDateString` in one, a "3 days ago" helper
|
|
25
|
+
* in the other). So: {@link RoleChip}, {@link AccountStateChip} and
|
|
26
|
+
* {@link dateColumn}, and the roster stays the app's `DataTable`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The chip look both rosters use: a square, uppercase status tag. Each can be changed. */
|
|
30
|
+
interface AccountChipLook {
|
|
31
|
+
/** Default `sm` — keksdose's roster; Kurvenschmiede's denser lists pass `xs`. */
|
|
32
|
+
size?: ChipSize;
|
|
33
|
+
/** Default `soft`. `dot` for a column of them that should not outshout the names. */
|
|
34
|
+
variant?: ChipVariant;
|
|
35
|
+
/** Default `square`: a chip in a table cell, beside square controls. */
|
|
36
|
+
shape?: ChipShape;
|
|
37
|
+
/** Default true — the status-badge type. */
|
|
38
|
+
caps?: boolean;
|
|
39
|
+
className?: string;
|
|
40
|
+
[key: `data-${string}`]: string | number | boolean | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── RoleChip ──────────────────────────────────────────────────────────────── */
|
|
44
|
+
|
|
45
|
+
/** How one role is drawn. `label` is the app's, translated. */
|
|
46
|
+
export interface RoleDefinition {
|
|
47
|
+
label: string;
|
|
48
|
+
tone?: ChipTone;
|
|
49
|
+
/** A lucide component or an element — Kurvenschmiede marks its admins with a shield. */
|
|
50
|
+
icon?: LucideIcon | ReactElement;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every role an app has, keyed by the value its API sends. Annotate the app's table
|
|
55
|
+
* with it (`const ROLES: RoleVocabulary<UserRole> = {…}`) and TypeScript asks for a
|
|
56
|
+
* label when a role is added.
|
|
57
|
+
*/
|
|
58
|
+
export type RoleVocabulary<R extends string = string> = Readonly<Record<R, RoleDefinition>>;
|
|
59
|
+
|
|
60
|
+
export interface RoleChipProps<R extends string = string> extends AccountChipLook {
|
|
61
|
+
/**
|
|
62
|
+
* The role key as the API sends it. `null` / `undefined` renders nothing. Named
|
|
63
|
+
* `value`, not `role`, because `role` on a JSX element is the ARIA role — the lint
|
|
64
|
+
* rule and every reader would take it for one.
|
|
65
|
+
*/
|
|
66
|
+
value: R | null | undefined;
|
|
67
|
+
/** What each key is called and how it is toned. A key missing here is shown as the
|
|
68
|
+
* key itself, in `neutral`: an unknown role is still a fact the admin should see. */
|
|
69
|
+
roles: Readonly<Partial<Record<R, RoleDefinition>>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A role as a chip: the API's key looked up in the app's {@link RoleVocabulary}.
|
|
74
|
+
*
|
|
75
|
+
* The vocabulary is a prop and not kit English, because the roles are the app's —
|
|
76
|
+
* Kurvenschmiede has admin, member and customer, keksdose admin and user, kastlan its
|
|
77
|
+
* own — and so are their names. Carries `data-role` with the key, for a test or a
|
|
78
|
+
* style hook.
|
|
79
|
+
*/
|
|
80
|
+
export function RoleChip<R extends string = string>({
|
|
81
|
+
value: role,
|
|
82
|
+
roles,
|
|
83
|
+
size = "sm",
|
|
84
|
+
variant,
|
|
85
|
+
shape = "square",
|
|
86
|
+
caps = true,
|
|
87
|
+
className,
|
|
88
|
+
...rest
|
|
89
|
+
}: RoleChipProps<R>) {
|
|
90
|
+
if (role === null || role === undefined || role === "") return null;
|
|
91
|
+
const def = roles[role];
|
|
92
|
+
return (
|
|
93
|
+
<Chip
|
|
94
|
+
{...rest}
|
|
95
|
+
data-role={role}
|
|
96
|
+
tone={def?.tone ?? "neutral"}
|
|
97
|
+
icon={def?.icon}
|
|
98
|
+
size={size}
|
|
99
|
+
variant={variant}
|
|
100
|
+
shape={shape}
|
|
101
|
+
caps={caps}
|
|
102
|
+
className={className}
|
|
103
|
+
>
|
|
104
|
+
{def?.label ?? role}
|
|
105
|
+
</Chip>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── AccountStateChip ──────────────────────────────────────────────────────── */
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The states both rosters show, each as the app's own flag maps onto it:
|
|
113
|
+
*
|
|
114
|
+
* - `active` / `inactive` — the account may sign in, or an admin switched it off
|
|
115
|
+
* (both apps' `is_active`).
|
|
116
|
+
* - `invited` — on the allowlist and mailed, not registered yet (an allowlist row).
|
|
117
|
+
* - `registered` — an allowlist row whose address has an account now (Kurvenschmiede).
|
|
118
|
+
* - `unverified` — the email address is not confirmed (keksdose).
|
|
119
|
+
* - `passwordChange` — must set a new password at the next sign-in (keksdose).
|
|
120
|
+
*
|
|
121
|
+
* An account can be several at once (inactive AND unverified): render one chip per
|
|
122
|
+
* state, as keksdose's state column does.
|
|
123
|
+
*/
|
|
124
|
+
export type AccountState = "active" | "inactive" | "invited" | "registered" | "unverified" | "passwordChange";
|
|
125
|
+
|
|
126
|
+
export type AccountStateLabels = Record<AccountState, string>;
|
|
127
|
+
|
|
128
|
+
export const DEFAULT_ACCOUNT_STATE_LABELS: AccountStateLabels = {
|
|
129
|
+
active: "Active",
|
|
130
|
+
inactive: "Inactive",
|
|
131
|
+
invited: "Invited",
|
|
132
|
+
registered: "Registered",
|
|
133
|
+
unverified: "Unverified",
|
|
134
|
+
passwordChange: "Must change password",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** The default tone of each state: green for the normal case, red for switched off,
|
|
138
|
+
* amber for something the user still has to do, blue for an open invitation. */
|
|
139
|
+
export const ACCOUNT_STATE_TONES: Readonly<Record<AccountState, ChipTone>> = {
|
|
140
|
+
active: "success",
|
|
141
|
+
inactive: "danger",
|
|
142
|
+
invited: "info",
|
|
143
|
+
registered: "neutral",
|
|
144
|
+
unverified: "warning",
|
|
145
|
+
passwordChange: "warning",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export interface AccountStateChipProps extends AccountChipLook {
|
|
149
|
+
state: AccountState;
|
|
150
|
+
/** Over {@link ACCOUNT_STATE_TONES} — Kurvenschmiede draws `inactive` neutral. */
|
|
151
|
+
tone?: ChipTone;
|
|
152
|
+
/** Over the label, for an app whose word for the state is its own ("Deactivated"). */
|
|
153
|
+
children?: ReactNode;
|
|
154
|
+
/** Prop > `<UiKitProvider labels={{ accountState }}>` > English. */
|
|
155
|
+
labels?: Partial<AccountStateLabels>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* An account's state as a chip, in the kit's words and tones. Carries `data-state`
|
|
160
|
+
* with the key.
|
|
161
|
+
*/
|
|
162
|
+
export function AccountStateChip({
|
|
163
|
+
state,
|
|
164
|
+
tone,
|
|
165
|
+
children,
|
|
166
|
+
labels: labelsProp,
|
|
167
|
+
size = "sm",
|
|
168
|
+
variant,
|
|
169
|
+
shape = "square",
|
|
170
|
+
caps = true,
|
|
171
|
+
className,
|
|
172
|
+
...rest
|
|
173
|
+
}: AccountStateChipProps) {
|
|
174
|
+
// An `undefined` in the prop is skipped (the provider's merge does that), so an
|
|
175
|
+
// optional value cannot blank a default.
|
|
176
|
+
const labels = useKitLabels("accountState", DEFAULT_ACCOUNT_STATE_LABELS, labelsProp);
|
|
177
|
+
return (
|
|
178
|
+
<Chip
|
|
179
|
+
{...rest}
|
|
180
|
+
data-state={state}
|
|
181
|
+
tone={tone ?? ACCOUNT_STATE_TONES[state]}
|
|
182
|
+
size={size}
|
|
183
|
+
variant={variant}
|
|
184
|
+
shape={shape}
|
|
185
|
+
caps={caps}
|
|
186
|
+
className={className}
|
|
187
|
+
>
|
|
188
|
+
{children ?? labels[state]}
|
|
189
|
+
</Chip>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ── Date columns ──────────────────────────────────────────────────────────── */
|
|
194
|
+
|
|
195
|
+
export interface DateMarkProps {
|
|
196
|
+
value: DateInput;
|
|
197
|
+
/**
|
|
198
|
+
* `"date"` (default): the date in `dateStyle` — "Created". `"relative"`: how long
|
|
199
|
+
* ago — "Last login", where the question is "recently?" — with the full date and
|
|
200
|
+
* time in a tooltip (keksdose's last-seen column).
|
|
201
|
+
*/
|
|
202
|
+
display?: "date" | "relative";
|
|
203
|
+
/** Default `medium`. Under `"relative"`, the style of the tooltip's date (default
|
|
204
|
+
* `dateTime`) and of the date `relative.absoluteAfterDays` switches to. */
|
|
205
|
+
dateStyle?: FormatDateStyle | Intl.DateTimeFormatOptions;
|
|
206
|
+
/** `formatRelativeTime` options: `numeric`, `style`, `absoluteAfterDays`, `now`. */
|
|
207
|
+
relative?: Omit<FormatRelativeTimeOptions, "locale" | "empty" | "absoluteStyle">;
|
|
208
|
+
/** What a missing value reads as. Default "—"; a last-login column passes its
|
|
209
|
+
* translated "Never". */
|
|
210
|
+
empty?: ReactNode;
|
|
211
|
+
/** Over the provider's locale. */
|
|
212
|
+
locale?: string;
|
|
213
|
+
className?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A date in a table cell: a `<time>` with a machine-readable `dateTime`, nowrap and
|
|
218
|
+
* tabular figures, in the provider's locale — or `empty` when there is none.
|
|
219
|
+
*/
|
|
220
|
+
export function DateMark({
|
|
221
|
+
value,
|
|
222
|
+
display = "date",
|
|
223
|
+
dateStyle,
|
|
224
|
+
relative,
|
|
225
|
+
empty = EMPTY_FORMATTED_VALUE,
|
|
226
|
+
locale: localeProp,
|
|
227
|
+
className,
|
|
228
|
+
}: DateMarkProps) {
|
|
229
|
+
const locale = useKitLocale(localeProp);
|
|
230
|
+
const date = toDate(value);
|
|
231
|
+
const cls = cn("whitespace-nowrap tabular-nums", className);
|
|
232
|
+
if (!date) return <span className={cn(cls, "text-[var(--text-muted)]")}>{empty}</span>;
|
|
233
|
+
if (display === "date") {
|
|
234
|
+
return (
|
|
235
|
+
<time dateTime={date.toISOString()} className={cls}>
|
|
236
|
+
{formatDate(date, dateStyle ?? "medium", { locale })}
|
|
237
|
+
</time>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const text = formatRelativeTime(date, { ...relative, locale, absoluteStyle: dateStyle ?? "medium" });
|
|
241
|
+
return (
|
|
242
|
+
<Tooltip label={formatDate(date, dateStyle ?? "dateTime", { locale })} lazy>
|
|
243
|
+
{/* Not a tab stop: a roster of fifty rows would be fifty more of them, and the
|
|
244
|
+
relative words already answer the column's question; the exact time is extra. */}
|
|
245
|
+
<time dateTime={date.toISOString()} className={cls}>
|
|
246
|
+
{text}
|
|
247
|
+
</time>
|
|
248
|
+
</Tooltip>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface DateColumnOptions<T> extends Omit<DateMarkProps, "value" | "className"> {
|
|
253
|
+
key: string;
|
|
254
|
+
header: ReactNode;
|
|
255
|
+
/** The value of this row: an ISO string, epoch ms, a Date, or null for none. */
|
|
256
|
+
value: (row: T) => DateInput;
|
|
257
|
+
/** Sortable by the instant; rows with no date sort last either way. Default true. */
|
|
258
|
+
sortable?: boolean;
|
|
259
|
+
/** Default `desc` — a date column is sorted to find the newest. */
|
|
260
|
+
firstSort?: "asc" | "desc";
|
|
261
|
+
/** Add the table's from–to date filter over the column. Default false. */
|
|
262
|
+
filter?: boolean;
|
|
263
|
+
/** Further column settings (`mobileHidden`, `headClassName`, …), merged over. */
|
|
264
|
+
column?: Partial<DataTableColumn<T>>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A {@link DataTableColumn} for a date: {@link DateMark} cells, sorted by the instant
|
|
269
|
+
* (newest first on the first click), optionally the date filter. The roster's
|
|
270
|
+
* "Created" is `dateColumn({ key, header, value })`; its "Last login" adds
|
|
271
|
+
* `display: "relative", empty: t("never")`.
|
|
272
|
+
*/
|
|
273
|
+
export function dateColumn<T>({
|
|
274
|
+
key,
|
|
275
|
+
header,
|
|
276
|
+
value,
|
|
277
|
+
sortable = true,
|
|
278
|
+
firstSort = "desc",
|
|
279
|
+
filter = false,
|
|
280
|
+
column,
|
|
281
|
+
...mark
|
|
282
|
+
}: DateColumnOptions<T>): DataTableColumn<T> {
|
|
283
|
+
return {
|
|
284
|
+
key,
|
|
285
|
+
header,
|
|
286
|
+
cell: (row) => <DateMark value={value(row)} {...mark} />,
|
|
287
|
+
sortBy: sortable ? (row) => toDate(value(row))?.getTime() ?? null : undefined,
|
|
288
|
+
firstSort: sortable ? firstSort : undefined,
|
|
289
|
+
filter: filter
|
|
290
|
+
? {
|
|
291
|
+
type: "date",
|
|
292
|
+
// The filter compares the first ten characters, so hand it an ISO string.
|
|
293
|
+
getValue: (row) => {
|
|
294
|
+
const raw = value(row);
|
|
295
|
+
if (typeof raw === "string") return raw;
|
|
296
|
+
return toDate(raw)?.toISOString() ?? null;
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
: undefined,
|
|
300
|
+
...column,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -63,8 +63,16 @@ export interface TwoFactorSettingLabels {
|
|
|
63
63
|
|
|
64
64
|
export interface PasskeysSettingLabels {
|
|
65
65
|
title: string;
|
|
66
|
-
/** The line under the title saying what a passkey is for
|
|
66
|
+
/** The line under the title saying what a passkey is for — `mode="instead"` (the
|
|
67
|
+
* default): the passkey stands in for the password. */
|
|
67
68
|
description: string;
|
|
69
|
+
/**
|
|
70
|
+
* The same line for `mode="alongside"` (0.18.0): the password keeps working and the
|
|
71
|
+
* passkey is a second way in. OPTIONAL for the reason `TwoFactorSettingLabels.qrAlt`
|
|
72
|
+
* is — this interface is annotated at call sites — and filled from the provider's
|
|
73
|
+
* `accountSettings.passkeys`, then English.
|
|
74
|
+
*/
|
|
75
|
+
descriptionAlongside?: string;
|
|
68
76
|
empty: string;
|
|
69
77
|
loading: string;
|
|
70
78
|
/** Accessible name of the list. */
|
|
@@ -98,8 +106,11 @@ export interface PasskeysSettingLabels {
|
|
|
98
106
|
export interface AccountSettingsLabels {
|
|
99
107
|
profile: ProfileSettingLabels;
|
|
100
108
|
password: PasswordSettingLabels;
|
|
109
|
+
// `Required` here, optional on the section interfaces: the namespace is what a full
|
|
110
|
+
// catalogue (`@eifi1/ui-kit/i18n/<code>`, `missingKitLabels`) is written against, so
|
|
111
|
+
// it must name the late keys; the section interfaces are annotated at call sites.
|
|
101
112
|
twoFactor: Required<TwoFactorSettingLabels>;
|
|
102
|
-
passkeys: PasskeysSettingLabels
|
|
113
|
+
passkeys: Required<PasskeysSettingLabels>;
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
|
|
@@ -139,6 +150,9 @@ export const DEFAULT_ACCOUNT_SETTINGS_LABELS: AccountSettingsLabels = {
|
|
|
139
150
|
passkeys: {
|
|
140
151
|
title: "Passkeys",
|
|
141
152
|
description: "Sign in with your fingerprint, face or device PIN instead of a password.",
|
|
153
|
+
// Kurvenschmiede overrode `description` because "instead of" is false where the
|
|
154
|
+
// password stays; this is the sentence for those apps, picked by `mode`.
|
|
155
|
+
descriptionAlongside: "Sign in with your fingerprint, face or device PIN. Your password keeps working too.",
|
|
142
156
|
empty: "No passkeys yet",
|
|
143
157
|
loading: "Loading passkeys…",
|
|
144
158
|
list: "Your passkeys",
|
package/src/components/chip.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, isValidElement, useId, useRef, useState } from "react";
|
|
1
|
+
import { forwardRef, isValidElement, useCallback, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
3
|
import { Check, X } from "lucide-react";
|
|
4
4
|
import type { LucideIcon } from "lucide-react";
|
|
@@ -282,6 +282,208 @@ const SIZE: Record<
|
|
|
282
282
|
},
|
|
283
283
|
};
|
|
284
284
|
|
|
285
|
+
/* ── Edge snapping (fractional device-pixel ratios) ──────────────────────────
|
|
286
|
+
*
|
|
287
|
+
* WHY. At a device-pixel ratio of 1.25 (a 125 % Windows display, or 125 % browser
|
|
288
|
+
* zoom) Chrome snaps a box to whole CSS pixels and only then scales it, so a 1px
|
|
289
|
+
* border is 1.25 device pixels wide and starts at a quarter-pixel phase that depends
|
|
290
|
+
* on where the edge lands. A chip's left edge follows whatever stands before it, its
|
|
291
|
+
* right edge follows its own text, so the two land on unrelated phases: one rasterises
|
|
292
|
+
* as a crisp pixel, the other as a two-pixel smear at half strength — and a 60 %-alpha
|
|
293
|
+
* border (`--danger-border`) makes the smeared side read as a THINNER line (keksdose
|
|
294
|
+
* dev #576).
|
|
295
|
+
*
|
|
296
|
+
* WHAT FIXES IT. The two edges rasterise as mirror images exactly when their device
|
|
297
|
+
* positions sum to a whole number: `(2·left + width) · dpr` integral, `left` being the
|
|
298
|
+
* edge's snapped CSS x. Rounding the WIDTH alone to the ratio's grid (4px at 1.25)
|
|
299
|
+
* makes the phases EQUAL, which is not the same thing: measured with keksdose's
|
|
300
|
+
* playwright harness across every left phase at 1.25, width-only rounding mirrored the
|
|
301
|
+
* edges at two of the four phases and left the other two as lopsided as before (mean
|
|
302
|
+
* peak asymmetry 16.5, worse than the unsnapped 10.9 a random width gets by luck); the
|
|
303
|
+
* position-aware width below took it to 0 at every phase. Inset rings, whole-pixel
|
|
304
|
+
* starts, compositing hints and fractional border widths were measured by keksdose
|
|
305
|
+
* and do not help — Chrome rounds a sub-pixel border back up to 1px.
|
|
306
|
+
*
|
|
307
|
+
* So the fix needs the chip's position, which is why it is opt-in (`snapEdges`): it
|
|
308
|
+
* reads layout. Cheaply — every snapped chip on the page shares one ResizeObserver,
|
|
309
|
+
* one `resize` listener and one resolution media query, and a pass is batched into a
|
|
310
|
+
* frame as read-all / write-all, not a forced layout per chip. At an integer ratio,
|
|
311
|
+
* and at a half one (1.5, 2.5: grid 2 — measured symmetric already, both edges a
|
|
312
|
+
* pixel and a half on the same pattern), there is nothing to fix and a pass
|
|
313
|
+
* writes nothing.
|
|
314
|
+
*/
|
|
315
|
+
|
|
316
|
+
/** The smallest whole CSS-pixel count `n` with `n × dpr` whole — 4 at 1.25, 2 at 1.5,
|
|
317
|
+
* 1 at an integer ratio. 0 for a ratio no step up to 4px fits (110 % zoom wants 10px;
|
|
318
|
+
* a chip growing by up to 9px is a layout change, not a hairline fix). */
|
|
319
|
+
function chipEdgeGrid(dpr: number): number {
|
|
320
|
+
for (let n = 1; n <= 4; n++) if (Math.abs(n * dpr - Math.round(n * dpr)) < 1e-3) return n;
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The width (whole CSS px, at least `natural`) that mirrors a box's two edges at
|
|
326
|
+
* `dpr`, for a box whose left edge sits at CSS x `left`. `natural` back when the ratio
|
|
327
|
+
* needs nothing.
|
|
328
|
+
*/
|
|
329
|
+
function snappedChipEdgeWidth(natural: number, left: number, dpr: number): number {
|
|
330
|
+
const n = chipEdgeGrid(dpr);
|
|
331
|
+
if (n <= 2) return natural;
|
|
332
|
+
const l = Math.round(left);
|
|
333
|
+
// A hair under a whole pixel is that pixel: 59.999 is a 60px chip, not a 61px one.
|
|
334
|
+
let w = Math.ceil(natural - 0.01);
|
|
335
|
+
while ((((2 * l + w) % n) + n) % n !== 0) w++;
|
|
336
|
+
return w;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const snapped = new Set<HTMLElement>();
|
|
340
|
+
/** The min-width each chip was given by a pass — so a pass only ever clears its own. */
|
|
341
|
+
const written = new WeakMap<HTMLElement, string>();
|
|
342
|
+
let snapFrame = 0;
|
|
343
|
+
let snapObserver: ResizeObserver | null = null;
|
|
344
|
+
let ratioQuery: MediaQueryList | null = null;
|
|
345
|
+
|
|
346
|
+
function clearOwn(el: HTMLElement) {
|
|
347
|
+
const own = written.get(el);
|
|
348
|
+
if (own !== undefined && el.style.minWidth === own) el.style.minWidth = "";
|
|
349
|
+
written.delete(el);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Re-snap every chip rendered with `snapEdges`, now. The kit already re-snaps when a
|
|
354
|
+
* chip renders, resizes, or the window or the ratio changes; call this after moving
|
|
355
|
+
* chips some other way (a side panel sliding in beside a table of status chips),
|
|
356
|
+
* since a move is not something an observer can see.
|
|
357
|
+
*/
|
|
358
|
+
export function refreshChipEdges(): void {
|
|
359
|
+
if (snapFrame) {
|
|
360
|
+
cancelAnimationFrame(snapFrame);
|
|
361
|
+
snapFrame = 0;
|
|
362
|
+
}
|
|
363
|
+
if (typeof window === "undefined" || !snapped.size) return;
|
|
364
|
+
const dpr = window.devicePixelRatio || 1;
|
|
365
|
+
const els = [...snapped];
|
|
366
|
+
// Write: back to the natural width, so what is read below is the chip, not our pin.
|
|
367
|
+
for (const el of els) clearOwn(el);
|
|
368
|
+
const n = chipEdgeGrid(dpr);
|
|
369
|
+
if (n <= 2) return;
|
|
370
|
+
// A chip whose own end decides where it starts (RTL, `justify-end`) moves when it
|
|
371
|
+
// grows, so a pass re-reads what it wrote and steps on by a pixel until the edges
|
|
372
|
+
// agree — `n` steps cover every residue. Each pass is ONE layout for every chip
|
|
373
|
+
// together. A chip that never agrees (a centred one moves both edges at once) is
|
|
374
|
+
// given its natural width back rather than kept wider for nothing.
|
|
375
|
+
let pending = els;
|
|
376
|
+
for (let pass = 0; pass <= n && pending.length; pass++) {
|
|
377
|
+
const reads = pending.map((el) => {
|
|
378
|
+
const r = el.getBoundingClientRect();
|
|
379
|
+
const label = el.querySelector<HTMLElement>(".truncate");
|
|
380
|
+
// Not laid out (a hidden ancestor, jsdom), or truncated at its container's edge,
|
|
381
|
+
// where a wider chip would overflow: leave it alone.
|
|
382
|
+
if (!r.width || (label && label.scrollWidth > label.clientWidth)) return null;
|
|
383
|
+
const width = Math.round(r.width * 64) / 64;
|
|
384
|
+
const target = snappedChipEdgeWidth(width, r.left + window.scrollX, dpr);
|
|
385
|
+
return target === width ? null : target;
|
|
386
|
+
});
|
|
387
|
+
const next: HTMLElement[] = [];
|
|
388
|
+
pending.forEach((el, i) => {
|
|
389
|
+
const target = reads[i];
|
|
390
|
+
if (target == null) return;
|
|
391
|
+
if (pass === n) {
|
|
392
|
+
clearOwn(el);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const own = written.get(el);
|
|
396
|
+
const value = `${pass > 0 && own !== undefined ? parseFloat(own) + 1 : target}px`;
|
|
397
|
+
el.style.minWidth = value;
|
|
398
|
+
written.set(el, value);
|
|
399
|
+
next.push(el);
|
|
400
|
+
});
|
|
401
|
+
pending = next;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function scheduleChipEdges() {
|
|
406
|
+
if (snapFrame || typeof requestAnimationFrame === "undefined") return;
|
|
407
|
+
snapFrame = requestAnimationFrame(() => {
|
|
408
|
+
snapFrame = 0;
|
|
409
|
+
refreshChipEdges();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The resolution query for the CURRENT ratio: it stops matching when the ratio
|
|
414
|
+
* changes (a window dragged to another display), which a `resize` need not fire. */
|
|
415
|
+
function watchRatio() {
|
|
416
|
+
ratioQuery?.removeEventListener("change", onRatioChange);
|
|
417
|
+
ratioQuery =
|
|
418
|
+
typeof window.matchMedia === "function"
|
|
419
|
+
? window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`)
|
|
420
|
+
: null;
|
|
421
|
+
ratioQuery?.addEventListener("change", onRatioChange);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function onRatioChange() {
|
|
425
|
+
watchRatio();
|
|
426
|
+
scheduleChipEdges();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function registerSnap(el: HTMLElement) {
|
|
430
|
+
if (snapped.has(el)) return;
|
|
431
|
+
if (!snapped.size) {
|
|
432
|
+
if (typeof ResizeObserver !== "undefined") snapObserver = new ResizeObserver(scheduleChipEdges);
|
|
433
|
+
window.addEventListener("resize", scheduleChipEdges);
|
|
434
|
+
watchRatio();
|
|
435
|
+
}
|
|
436
|
+
snapped.add(el);
|
|
437
|
+
snapObserver?.observe(el);
|
|
438
|
+
scheduleChipEdges();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function unregisterSnap(el: HTMLElement) {
|
|
442
|
+
if (!snapped.delete(el)) return;
|
|
443
|
+
snapObserver?.unobserve(el);
|
|
444
|
+
clearOwn(el);
|
|
445
|
+
if (snapped.size) return;
|
|
446
|
+
snapObserver?.disconnect();
|
|
447
|
+
snapObserver = null;
|
|
448
|
+
window.removeEventListener("resize", scheduleChipEdges);
|
|
449
|
+
ratioQuery?.removeEventListener("change", onRatioChange);
|
|
450
|
+
ratioQuery = null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The ref a chip hangs on its visible pill, plus a re-snap after every render (its
|
|
454
|
+
* label or its neighbours may have changed). */
|
|
455
|
+
function useSnapEdges(on: boolean) {
|
|
456
|
+
const node = useRef<HTMLElement | null>(null);
|
|
457
|
+
const ref = useCallback(
|
|
458
|
+
(el: HTMLElement | null) => {
|
|
459
|
+
if (el === node.current) return;
|
|
460
|
+
const prev = node.current;
|
|
461
|
+
node.current = el;
|
|
462
|
+
// React detaches and re-attaches a ref whose callback changed identity (a
|
|
463
|
+
// consumer's inline ref) — the same element, a moment apart. Unregistering on
|
|
464
|
+
// the detach would clear the pin and paint one frame unsnapped, so the drop
|
|
465
|
+
// waits a microtask and is skipped if the element came straight back.
|
|
466
|
+
if (prev) queueMicrotask(() => node.current !== prev && unregisterSnap(prev));
|
|
467
|
+
if (el && on) registerSnap(el);
|
|
468
|
+
},
|
|
469
|
+
[on],
|
|
470
|
+
);
|
|
471
|
+
useLayoutEffect(() => {
|
|
472
|
+
const el = node.current;
|
|
473
|
+
if (!el) return;
|
|
474
|
+
if (on) {
|
|
475
|
+
registerSnap(el);
|
|
476
|
+
scheduleChipEdges();
|
|
477
|
+
} else unregisterSnap(el);
|
|
478
|
+
});
|
|
479
|
+
return ref;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function assignRef<T>(target: Ref<T> | undefined, value: T | null) {
|
|
483
|
+
if (typeof target === "function") target(value);
|
|
484
|
+
else if (target) (target as { current: T | null }).current = value;
|
|
485
|
+
}
|
|
486
|
+
|
|
285
487
|
const CHIP_PILL = "inline-flex max-w-full items-center rounded-full border transition-colors";
|
|
286
488
|
// `focus-visible`, not `focus`: a chip commonly receives focus programmatically (the
|
|
287
489
|
// ChipInput moves focus onto one after a removal) and a ring that appears on a
|
|
@@ -372,6 +574,23 @@ interface ChipBaseProps {
|
|
|
372
574
|
id?: string;
|
|
373
575
|
/** A native tooltip. For anything a user must read, prefer the kit's `Tooltip`. */
|
|
374
576
|
title?: string;
|
|
577
|
+
/**
|
|
578
|
+
* Even hairlines on fractional-ratio displays (0.18.0). At a device-pixel ratio of
|
|
579
|
+
* 1.25 — a 125 % Windows display, or 125 % zoom — Chrome draws a chip's two 1px
|
|
580
|
+
* side borders on different sub-pixel phases, one crisp and one a half-strength
|
|
581
|
+
* smear that reads as a thinner line (worst on the 60 %-alpha `danger` border).
|
|
582
|
+
* `snapEdges` widens the chip by under 4px, from where it sits, so the two edges
|
|
583
|
+
* rasterise as mirror images, and centres its content in the extra.
|
|
584
|
+
*
|
|
585
|
+
* Off by default: it reads the chip's position (batched for every snapped chip into
|
|
586
|
+
* one pass a frame, through one shared observer), and the fix is only as fresh as the
|
|
587
|
+
* last pass — it re-snaps on render, resize, zoom and ratio change, but not when
|
|
588
|
+
* something else moves the chip; call {@link refreshChipEdges} then. At an integer
|
|
589
|
+
* ratio, and at 1.5, it writes nothing. Turn it on for status chips that sit in a
|
|
590
|
+
* column and are compared side by side; a chip with no visible border (`dot`) has
|
|
591
|
+
* nothing to snap.
|
|
592
|
+
*/
|
|
593
|
+
snapEdges?: boolean;
|
|
375
594
|
}
|
|
376
595
|
|
|
377
596
|
/**
|
|
@@ -458,6 +677,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
458
677
|
role,
|
|
459
678
|
onKeyDown,
|
|
460
679
|
onFocus,
|
|
680
|
+
snapEdges = false,
|
|
461
681
|
...rest
|
|
462
682
|
},
|
|
463
683
|
ref,
|
|
@@ -476,6 +696,17 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
476
696
|
surfaceOf(tone, variant, selected),
|
|
477
697
|
disabled && "cursor-default opacity-50",
|
|
478
698
|
);
|
|
699
|
+
const snap = snapEdges && !dot;
|
|
700
|
+
const snapRef = useSnapEdges(snap);
|
|
701
|
+
// The visible pill carries the snap: the whole chip, or the wrapper of a split one
|
|
702
|
+
// (whose body gets the forwarded ref instead).
|
|
703
|
+
const ownRef = useCallback(
|
|
704
|
+
(el: HTMLElement | null) => {
|
|
705
|
+
snapRef(el);
|
|
706
|
+
assignRef(ref, el);
|
|
707
|
+
},
|
|
708
|
+
[snapRef, ref],
|
|
709
|
+
);
|
|
479
710
|
const look = cn(
|
|
480
711
|
CHIP_BASE,
|
|
481
712
|
s.body,
|
|
@@ -485,6 +716,8 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
485
716
|
interactive && !disabled && "cursor-pointer hover:brightness-[0.97] dark:hover:brightness-110",
|
|
486
717
|
// An inert dot chip is text in a column: no inset, so it aligns with the header.
|
|
487
718
|
dot && !interactive && "px-0",
|
|
719
|
+
// The under-4px a snap adds is split between the two sides, not stacked at the end.
|
|
720
|
+
snap && "justify-center",
|
|
488
721
|
className,
|
|
489
722
|
);
|
|
490
723
|
|
|
@@ -568,7 +801,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
568
801
|
|
|
569
802
|
// ── The combined shape: one pill, two interactive siblings inside it.
|
|
570
803
|
const pill = (inner: ReactNode) => (
|
|
571
|
-
<span className={cn(CHIP_PILL, s.tail, radius, surface, className)}>
|
|
804
|
+
<span ref={snap ? snapRef : undefined} className={cn(CHIP_PILL, s.tail, radius, surface, snap && "justify-center", className)}>
|
|
572
805
|
{inner}
|
|
573
806
|
{remove}
|
|
574
807
|
</span>
|
|
@@ -591,7 +824,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
591
824
|
const onLinkClick = onClick as ChipLinkProps["onClick"];
|
|
592
825
|
const linkProps: ChipLinkProps = {
|
|
593
826
|
...rest,
|
|
594
|
-
ref: ref as Ref<HTMLAnchorElement>,
|
|
827
|
+
ref: (remove ? ref : ownRef) as Ref<HTMLAnchorElement>,
|
|
595
828
|
href,
|
|
596
829
|
onClick: onLinkClick,
|
|
597
830
|
"aria-current": selected ? "true" : undefined,
|
|
@@ -605,7 +838,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
605
838
|
<RenderedLink render={render} {...linkProps} />
|
|
606
839
|
) : (
|
|
607
840
|
<a
|
|
608
|
-
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
841
|
+
ref={(remove ? ref : ownRef) as React.Ref<HTMLAnchorElement>}
|
|
609
842
|
href={href}
|
|
610
843
|
aria-current={selected ? "true" : undefined}
|
|
611
844
|
onClick={onLinkClick}
|
|
@@ -624,7 +857,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
624
857
|
const onButtonClick = onClick as (event: MouseEvent<HTMLButtonElement>) => void;
|
|
625
858
|
const button = (
|
|
626
859
|
<button
|
|
627
|
-
ref={ref as React.Ref<HTMLButtonElement>}
|
|
860
|
+
ref={(remove ? ref : ownRef) as React.Ref<HTMLButtonElement>}
|
|
628
861
|
type="button"
|
|
629
862
|
onClick={onButtonClick}
|
|
630
863
|
disabled={disabled}
|
|
@@ -650,7 +883,7 @@ export const Chip = forwardRef<HTMLElement, ChipProps>(function Chip(
|
|
|
650
883
|
the roving-focus hooks, live only when the container passes `role` and `tabIndex`
|
|
651
884
|
(ChipInput does); the role is a prop, so the rule cannot see it. */
|
|
652
885
|
<span
|
|
653
|
-
ref={
|
|
886
|
+
ref={ownRef as React.Ref<HTMLSpanElement>}
|
|
654
887
|
className={look}
|
|
655
888
|
tabIndex={tabIndex}
|
|
656
889
|
role={role}
|