@eifi1/ui-kit 0.50.0 → 0.51.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 +14 -12
- package/dist/admin/user-roster.js +31 -12
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/verify-email.d.ts +4 -0
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/alert-banner.d.ts +9 -0
- package/dist/components/alert-banner.js +9 -5
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/autocomplete.js +13 -0
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-look.d.ts +4 -19
- package/dist/components/button-look.js +3 -30
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/combobox.js +3 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-virtual.js +1 -0
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/field-base.d.ts +31 -1
- package/dist/components/field-base.js +28 -0
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/floating-panel.d.ts +6 -13
- package/dist/components/floating-panel.js +2 -9
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/icon-button.js +3 -5
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +12 -0
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/select.js +3 -2
- package/dist/components/select.js.map +1 -1
- package/dist/components/stat-tile.d.ts +8 -1
- package/dist/components/stat-tile.js +7 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/german.js +2 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +2 -0
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +2 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +2 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +2 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +2 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +2 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/page-seo.d.ts +19 -11
- package/dist/landing/page-seo.js +11 -7
- package/dist/landing/page-seo.js.map +1 -1
- package/dist/landing/public-page.d.ts +57 -0
- package/dist/landing/public-page.js +45 -0
- package/dist/landing/public-page.js.map +1 -0
- package/dist/lib/quiet-focus.d.ts +4 -0
- package/dist/lib/quiet-focus.js +19 -0
- package/dist/lib/quiet-focus.js.map +1 -0
- package/dist/lib/server-wake.d.ts +3 -2
- package/dist/lib/server-wake.js +19 -13
- package/dist/lib/server-wake.js.map +1 -1
- package/dist/shell/app-shell.d.ts +2 -15
- package/dist/shell/app-shell.js +7 -26
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-menu.d.ts +98 -0
- package/dist/shell/dev-menu.js +133 -0
- package/dist/shell/dev-menu.js.map +1 -0
- package/dist/shell/dev-switchers.d.ts +6 -53
- package/dist/shell/dev-switchers.js +5 -100
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-actions.d.ts +4 -1
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +18 -9
- package/dist/shell/top-bar-parts-labels.js +2 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +17 -2
- package/dist/shell/topbar-action-menu.js +3 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +3 -1
- package/dist/shell.js +3 -1
- package/dist/shell.js.map +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/tour.d.ts +31 -0
- package/dist/testing/tour.js +12 -0
- package/dist/testing/tour.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +0 -3
- package/dist/theme/palette-presets.js +0 -2
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/tour/tour-engine.d.ts +12 -0
- package/dist/tour/tour-engine.js +31 -0
- package/dist/tour/tour-engine.js.map +1 -0
- package/dist/tour/tour.d.ts +14 -3
- package/dist/tour/tour.js +60 -45
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-url-sync.d.ts +5 -3
- package/dist/tour/use-tour-url-sync.js +21 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/user-roster.tsx +30 -10
- package/src/auth/verify-email.tsx +4 -0
- package/src/components/alert-banner.tsx +37 -10
- package/src/components/autocomplete.tsx +18 -0
- package/src/components/button-look.ts +7 -57
- package/src/components/combobox.tsx +5 -1
- package/src/components/data-table-fit.ts +4 -1
- package/src/components/data-table-virtual.ts +14 -1
- package/src/components/field-base.tsx +53 -1
- package/src/components/floating-panel.tsx +6 -28
- package/src/components/icon-button.tsx +6 -8
- package/src/components/inline-entity-combobox.tsx +17 -0
- package/src/components/select.tsx +3 -6
- package/src/components/stat-tile.tsx +27 -9
- package/src/i18n/german.ts +2 -0
- package/src/i18n/kit-labels.tsx +2 -2
- package/src/i18n/locales/es.ts +2 -0
- package/src/i18n/locales/fr.ts +2 -0
- package/src/i18n/locales/hu.ts +2 -0
- package/src/i18n/locales/it.ts +2 -0
- package/src/i18n/locales/zh.ts +2 -0
- package/src/index.ts +4 -1
- package/src/landing/page-seo.ts +28 -16
- package/src/landing/public-page.tsx +106 -0
- package/src/lib/quiet-focus.ts +32 -0
- package/src/lib/server-wake.ts +41 -17
- package/src/shell/app-shell.tsx +11 -48
- package/src/shell/dev-menu.tsx +259 -0
- package/src/shell/dev-switchers.tsx +13 -174
- package/src/shell/top-bar-actions.tsx +4 -1
- package/src/shell/top-bar-parts-labels.ts +20 -9
- package/src/shell/topbar-action-menu.tsx +28 -2
- package/src/shell.ts +5 -1
- package/src/testing/index.ts +8 -0
- package/src/testing/tour.ts +41 -0
- package/src/theme/palette-presets.ts +0 -4
- package/src/tour/tour-engine.ts +41 -0
- package/src/tour/tour.tsx +64 -24
- package/src/tour/use-tour-url-sync.ts +33 -4
|
@@ -4,6 +4,7 @@ import * as ReactRouter from "react-router";
|
|
|
4
4
|
import { useLocation, useNavigate } from "react-router";
|
|
5
5
|
import { useSearchParamsState } from "../hooks/use-search-param-state.js";
|
|
6
6
|
import { useTourOptional } from "./tour.js";
|
|
7
|
+
import { liveEngine } from "./tour-engine.js";
|
|
7
8
|
import { devWarnings } from "./tour-gates.js";
|
|
8
9
|
function navigationInFlight(history, dataRouter, rendered) {
|
|
9
10
|
const state = dataRouter?.router?.state;
|
|
@@ -51,10 +52,12 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
|
|
|
51
52
|
const aliases = stepAliases.join("\n");
|
|
52
53
|
const launchRef = useRef(launch);
|
|
53
54
|
const startRef = useRef(engine?.start ?? noProviderStart);
|
|
55
|
+
const [live] = useState(() => liveEngine(ENGINE_KEYS, NO_PROVIDER_ENGINE));
|
|
54
56
|
const activeIdRef = useRef(activeId);
|
|
55
57
|
useLayoutEffect(() => {
|
|
56
58
|
launchRef.current = launch;
|
|
57
59
|
startRef.current = engine?.start ?? noProviderStart;
|
|
60
|
+
live.set(engine ?? NO_PROVIDER_ENGINE);
|
|
58
61
|
activeIdRef.current = active ? activeId : null;
|
|
59
62
|
});
|
|
60
63
|
const seenRef = useRef(null);
|
|
@@ -70,7 +73,7 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
|
|
|
70
73
|
if (!pending || !ready) return;
|
|
71
74
|
pendingRef.current = null;
|
|
72
75
|
launchingRef.current = true;
|
|
73
|
-
void Promise.resolve().then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current)).catch(() => void 0).finally(() => {
|
|
76
|
+
void Promise.resolve().then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine)).catch(() => void 0).finally(() => {
|
|
74
77
|
launchingRef.current = false;
|
|
75
78
|
setSettled((n) => n + 1);
|
|
76
79
|
});
|
|
@@ -95,6 +98,23 @@ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
|
|
|
95
98
|
const noProviderStart = () => {
|
|
96
99
|
if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.");
|
|
97
100
|
};
|
|
101
|
+
const noProviderCall = () => {
|
|
102
|
+
if (devWarnings()) console.warn("[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.");
|
|
103
|
+
};
|
|
104
|
+
const NO_PROVIDER_ENGINE = {
|
|
105
|
+
active: false,
|
|
106
|
+
activeId: null,
|
|
107
|
+
start: noProviderStart,
|
|
108
|
+
stop: noProviderCall,
|
|
109
|
+
next: noProviderCall,
|
|
110
|
+
prev: noProviderCall,
|
|
111
|
+
goToStep: noProviderCall,
|
|
112
|
+
index: 0,
|
|
113
|
+
total: 0,
|
|
114
|
+
position: 0,
|
|
115
|
+
count: 0
|
|
116
|
+
};
|
|
117
|
+
const ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE);
|
|
98
118
|
const STATES = ["all", "todo", "done"];
|
|
99
119
|
function useTourCatalogueUrlState(params = {}) {
|
|
100
120
|
const [filter, setFilter] = useSearchParamsState({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tour/use-tour-url-sync.ts"],"sourcesContent":["import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, type Navigator } from \"react-router\";\nimport { useSearchParamsState } from \"../hooks/use-search-param-state\";\nimport { useTourOptional, type TourStart } from \"./tour\";\nimport { devWarnings } from \"./tour-gates\";\n\n/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */\n\nexport interface UseTourUrlSyncOptions {\n /**\n * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the\n * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points\n * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:\n * the parameters are kept until it settles. Under a `TourProvider` the third argument is\n * that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves\n * both. Without a provider it is a `start` that does nothing (a development build warns).\n */\n launch: (id: string, options: { startIndex: number }, start: TourStart) => void | Promise<unknown>;\n /**\n * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the\n * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so\n * an F5 mid-tour resumes the right variant at the right step. Default `true`.\n */\n ready?: boolean;\n /**\n * Older names of `tourStep`, read when it is absent and removed with it — kastlan's\n * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.\n */\n stepAliases?: readonly string[];\n}\n\ntype Place = { pathname: string; search: string; hash: string };\n\n/**\n * Has the router already moved on from the location React is still rendering?\n * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built\n * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.\n *\n * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build\n * review 0.43, kastlan A2), so the router's own state answers — a navigation still\n * loading, or a location it has committed that React has not rendered yet.\n * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and\n * carries where it is.\n * - Anything else answers \"not in flight\".\n */\nfunction navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {\n const state = dataRouter?.router?.state;\n if (state && (state.navigation?.state ?? \"idle\") !== \"idle\") return true;\n const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;\n if (!live) return false;\n return (\n live.pathname !== rendered.pathname || (live.search ?? \"\") !== rendered.search || (live.hash ?? \"\") !== rendered.hash\n );\n}\n\ntype NavigationProbe = { navigator?: Navigator } | null;\nconst NO_NAVIGATION = createContext<NavigationProbe>(null);\n/** The data router's state, as `useSearchParamState` reads it. */\ntype DataRouterProbe = {\n router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };\n} | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n})();\n/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:\n * a test's partial `vi.mock(\"react-router\")` throws on an export it does not define. */\nconst NavigationContext: Context<NavigationProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??\n NO_NAVIGATION\n );\n } catch {\n return NO_NAVIGATION;\n }\n})();\n\n/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */\nfunction stepOf(params: URLSearchParams, aliases: readonly string[]): number {\n const step = Number(params.get(\"tourStep\"));\n if (params.has(\"tourStep\")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;\n for (const alias of aliases) {\n const value = Number(params.get(alias));\n if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;\n }\n return 0;\n}\n\n/**\n * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,\n * on the kit's engine (`useTourOptional`):\n *\n * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app\n * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —\n * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.\n * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied\n * link pins the step (kk K-3). A step that navigates drops them; this puts them back.\n * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.\n *\n * Mount it once inside the router and the `TourProvider`. Without a provider it still\n * launches through `launch` and never mirrors.\n */\nexport function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {\n const navigate = useNavigate();\n const location = useLocation();\n const { pathname, search, hash } = location;\n const history = useContext(NavigationContext)?.navigator;\n const dataRouter = useContext(DataRouterContext);\n const engine = useTourOptional();\n const active = engine?.active ?? false;\n const activeId = engine?.activeId ?? null;\n const index = engine?.index ?? 0;\n const urlTour = new URLSearchParams(search).get(\"tour\");\n const aliases = stepAliases.join(\"\\n\");\n\n const launchRef = useRef(launch);\n const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);\n const activeIdRef = useRef(activeId);\n useLayoutEffect(() => {\n launchRef.current = launch;\n startRef.current = engine?.start ?? noProviderStart;\n activeIdRef.current = active ? activeId : null;\n });\n /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */\n const seenRef = useRef<string | null>(null);\n /** A deep link waiting for `ready`. */\n const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);\n /** A launch under way (an app's `prepare()`). */\n const launchingRef = useRef(false);\n /** Bumped when a launch settles, so the mirror looks again. */\n const [settled, setSettled] = useState(0);\n\n useEffect(() => {\n if (urlTour !== seenRef.current) {\n seenRef.current = urlTour;\n // Its own mirror writing the running tour back is no new link.\n pendingRef.current =\n urlTour && urlTour !== activeIdRef.current\n ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split(\"\\n\") : []) }\n : null;\n }\n const pending = pendingRef.current;\n if (!pending || !ready) return;\n pendingRef.current = null;\n launchingRef.current = true;\n void Promise.resolve()\n .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current))\n .catch(() => undefined)\n .finally(() => {\n launchingRef.current = false;\n setSettled((n) => n + 1);\n });\n // Only a change of the parameter, or readiness, starts a tour.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlTour, ready]);\n\n useEffect(() => {\n if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;\n // A link not yet used keeps its parameters: a reload in that window resumes.\n if (pendingRef.current || launchingRef.current) return;\n const tourId = active && activeId ? activeId : null;\n const own = [\"tour\", \"tourStep\", ...(aliases ? aliases.split(\"\\n\") : [])];\n const current = new URLSearchParams(search);\n // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace\n // clobbers a page's own just-issued navigation, keksdose feedback #4).\n if (!tourId && !own.some((key) => current.has(key))) return;\n const next = new URLSearchParams(search);\n // `set` keeps a parameter where it stands; only the aliases (and, at the end, the\n // tour's own) go.\n for (const key of tourId ? own.slice(2) : own) next.delete(key);\n if (tourId) {\n next.set(\"tour\", tourId);\n next.set(\"tourStep\", String(index + 1));\n }\n if (next.toString() === current.toString()) return;\n // Spelled out, not `setSearchParams` (\"?…\" drops the hash, keksdose dev#495).\n void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active, activeId, index, pathname, search, hash, settled, aliases]);\n}\n\n/** `start` for a launcher run with no `TourProvider` above `useTourUrlSync`. */\nconst noProviderStart: TourStart = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.\");\n};\n\n/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */\n\n/** The catalogue's state filter. */\nexport type TourStateFilter = \"all\" | \"todo\" | \"done\";\n\n/** The catalogue's filters: the search, the group (`null`: every group), the state. */\nexport interface TourCatalogueFilter {\n query: string;\n group: string | null;\n state: TourStateFilter;\n}\n\nconst STATES: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each\n * written with `replace` (typing does not bury the Back button), the defaults as the\n * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.\n * `params` renames them (kastlan's `?group`). Requires a react-router Router.\n */\nexport function useTourCatalogueUrlState(\n params: { query?: string; group?: string; state?: string } = {},\n): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {\n const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({\n query: { param: params.query ?? \"q\", default: \"\", history: \"replace\" },\n group: { param: params.group ?? \"topic\", default: null, history: \"replace\", parse: (raw) => raw || undefined },\n state: {\n param: params.state ?? \"state\",\n default: \"all\",\n history: \"replace\",\n parse: (raw) => STATES.find((x) => x === raw),\n },\n });\n // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.\n return { ...filter, onChange: setFilter };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/tour/use-tour-url-sync.ts"],"sourcesContent":["import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, type Navigator } from \"react-router\";\nimport { useSearchParamsState } from \"../hooks/use-search-param-state\";\nimport { useTourOptional, type TourEngine, type TourStart } from \"./tour\";\nimport { liveEngine } from \"./tour-engine\";\nimport { devWarnings } from \"./tour-gates\";\n\n/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */\n\nexport interface UseTourUrlSyncOptions {\n /**\n * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the\n * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points\n * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:\n * the parameters are kept until it settles. Under a `TourProvider` the third argument is\n * that provider's `start` (0.50, as `TourProvider launch` gets it), so one launcher serves\n * both; the fourth is its whole engine, read live (0.50.1, `TourEngine`: `stop`, `next`,\n * `active`, …). Without a provider `start` and the engine's methods do nothing (a\n * development build warns) and the engine reads as idle.\n */\n launch: (\n id: string,\n options: { startIndex: number },\n start: TourStart,\n engine: TourEngine,\n ) => void | Promise<unknown>;\n /**\n * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the\n * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so\n * an F5 mid-tour resumes the right variant at the right step. Default `true`.\n */\n ready?: boolean;\n /**\n * Older names of `tourStep`, read when it is absent and removed with it — kastlan's\n * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.\n */\n stepAliases?: readonly string[];\n}\n\ntype Place = { pathname: string; search: string; hash: string };\n\n/**\n * Has the router already moved on from the location React is still rendering?\n * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built\n * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.\n *\n * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build\n * review 0.43, kastlan A2), so the router's own state answers — a navigation still\n * loading, or a location it has committed that React has not rendered yet.\n * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and\n * carries where it is.\n * - Anything else answers \"not in flight\".\n */\nfunction navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {\n const state = dataRouter?.router?.state;\n if (state && (state.navigation?.state ?? \"idle\") !== \"idle\") return true;\n const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;\n if (!live) return false;\n return (\n live.pathname !== rendered.pathname || (live.search ?? \"\") !== rendered.search || (live.hash ?? \"\") !== rendered.hash\n );\n}\n\ntype NavigationProbe = { navigator?: Navigator } | null;\nconst NO_NAVIGATION = createContext<NavigationProbe>(null);\n/** The data router's state, as `useSearchParamState` reads it. */\ntype DataRouterProbe = {\n router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };\n} | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n})();\n/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:\n * a test's partial `vi.mock(\"react-router\")` throws on an export it does not define. */\nconst NavigationContext: Context<NavigationProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??\n NO_NAVIGATION\n );\n } catch {\n return NO_NAVIGATION;\n }\n})();\n\n/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */\nfunction stepOf(params: URLSearchParams, aliases: readonly string[]): number {\n const step = Number(params.get(\"tourStep\"));\n if (params.has(\"tourStep\")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;\n for (const alias of aliases) {\n const value = Number(params.get(alias));\n if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;\n }\n return 0;\n}\n\n/**\n * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,\n * on the kit's engine (`useTourOptional`):\n *\n * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app\n * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —\n * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.\n * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied\n * link pins the step (kk K-3). A step that navigates drops them; this puts them back.\n * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.\n *\n * Mount it once inside the router and the `TourProvider`. Without a provider it still\n * launches through `launch` and never mirrors.\n */\nexport function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {\n const navigate = useNavigate();\n const location = useLocation();\n const { pathname, search, hash } = location;\n const history = useContext(NavigationContext)?.navigator;\n const dataRouter = useContext(DataRouterContext);\n const engine = useTourOptional();\n const active = engine?.active ?? false;\n const activeId = engine?.activeId ?? null;\n const index = engine?.index ?? 0;\n const urlTour = new URLSearchParams(search).get(\"tour\");\n const aliases = stepAliases.join(\"\\n\");\n\n const launchRef = useRef(launch);\n const startRef = useRef<TourStart>(engine?.start ?? noProviderStart);\n const [live] = useState(() => liveEngine<TourEngine>(ENGINE_KEYS, NO_PROVIDER_ENGINE));\n const activeIdRef = useRef(activeId);\n useLayoutEffect(() => {\n launchRef.current = launch;\n startRef.current = engine?.start ?? noProviderStart;\n live.set(engine ?? NO_PROVIDER_ENGINE);\n activeIdRef.current = active ? activeId : null;\n });\n /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */\n const seenRef = useRef<string | null>(null);\n /** A deep link waiting for `ready`. */\n const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);\n /** A launch under way (an app's `prepare()`). */\n const launchingRef = useRef(false);\n /** Bumped when a launch settles, so the mirror looks again. */\n const [settled, setSettled] = useState(0);\n\n useEffect(() => {\n if (urlTour !== seenRef.current) {\n seenRef.current = urlTour;\n // Its own mirror writing the running tour back is no new link.\n pendingRef.current =\n urlTour && urlTour !== activeIdRef.current\n ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split(\"\\n\") : []) }\n : null;\n }\n const pending = pendingRef.current;\n if (!pending || !ready) return;\n pendingRef.current = null;\n launchingRef.current = true;\n void Promise.resolve()\n .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }, startRef.current, live.engine))\n .catch(() => undefined)\n .finally(() => {\n launchingRef.current = false;\n setSettled((n) => n + 1);\n });\n // Only a change of the parameter, or readiness, starts a tour.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlTour, ready]);\n\n useEffect(() => {\n if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;\n // A link not yet used keeps its parameters: a reload in that window resumes.\n if (pendingRef.current || launchingRef.current) return;\n const tourId = active && activeId ? activeId : null;\n const own = [\"tour\", \"tourStep\", ...(aliases ? aliases.split(\"\\n\") : [])];\n const current = new URLSearchParams(search);\n // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace\n // clobbers a page's own just-issued navigation, keksdose feedback #4).\n if (!tourId && !own.some((key) => current.has(key))) return;\n const next = new URLSearchParams(search);\n // `set` keeps a parameter where it stands; only the aliases (and, at the end, the\n // tour's own) go.\n for (const key of tourId ? own.slice(2) : own) next.delete(key);\n if (tourId) {\n next.set(\"tour\", tourId);\n next.set(\"tourStep\", String(index + 1));\n }\n if (next.toString() === current.toString()) return;\n // Spelled out, not `setSearchParams` (\"?…\" drops the hash, keksdose dev#495).\n void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active, activeId, index, pathname, search, hash, settled, aliases]);\n}\n\n/** `start` for a launcher run with no `TourProvider` above `useTourUrlSync`. */\nconst noProviderStart: TourStart = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's start needs a TourProvider above it.\");\n};\n\nconst noProviderCall = () => {\n if (devWarnings()) console.warn(\"[ui-kit] useTourUrlSync: the launcher's engine needs a TourProvider above it.\");\n};\n/** The engine for a launcher run with no `TourProvider` above `useTourUrlSync`: idle. */\nconst NO_PROVIDER_ENGINE: TourEngine = {\n active: false,\n activeId: null,\n start: noProviderStart,\n stop: noProviderCall,\n next: noProviderCall,\n prev: noProviderCall,\n goToStep: noProviderCall,\n index: 0,\n total: 0,\n position: 0,\n count: 0,\n};\nconst ENGINE_KEYS = Object.keys(NO_PROVIDER_ENGINE) as (keyof TourEngine)[];\n\n/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */\n\n/** The catalogue's state filter. */\nexport type TourStateFilter = \"all\" | \"todo\" | \"done\";\n\n/** The catalogue's filters: the search, the group (`null`: every group), the state. */\nexport interface TourCatalogueFilter {\n query: string;\n group: string | null;\n state: TourStateFilter;\n}\n\nconst STATES: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each\n * written with `replace` (typing does not bury the Back button), the defaults as the\n * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.\n * `params` renames them (kastlan's `?group`). Requires a react-router Router.\n */\nexport function useTourCatalogueUrlState(\n params: { query?: string; group?: string; state?: string } = {},\n): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {\n const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({\n query: { param: params.query ?? \"q\", default: \"\", history: \"replace\" },\n group: { param: params.group ?? \"topic\", default: null, history: \"replace\", parse: (raw) => raw || undefined },\n state: {\n param: params.state ?? \"state\",\n default: \"all\",\n history: \"replace\",\n parse: (raw) => STATES.find((x) => x === raw),\n },\n });\n // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.\n return { ...filter, onChange: setFilter };\n}\n"],"mappings":";AAAA,SAAS,eAAe,YAAY,WAAW,iBAAiB,QAAQ,gBAA8B;AACtG,YAAY,iBAAiB;AAC7B,SAAS,aAAa,mBAAmC;AACzD,SAAS,4BAA4B;AACrC,SAAS,uBAAwD;AACjE,SAAS,kBAAkB;AAC3B,SAAS,mBAAmB;AAgD5B,SAAS,mBAAmB,SAAgC,YAA6B,UAAiB;AACxG,QAAM,QAAQ,YAAY,QAAQ;AAClC,MAAI,UAAU,MAAM,YAAY,SAAS,YAAY,OAAQ,QAAO;AACpE,QAAM,OAAO,OAAO,YAAa,SAAqE;AACtG,MAAI,CAAC,KAAM,QAAO;AAClB,SACE,KAAK,aAAa,SAAS,aAAa,KAAK,UAAU,QAAQ,SAAS,WAAW,KAAK,QAAQ,QAAQ,SAAS;AAErH;AAGA,MAAM,gBAAgB,cAA+B,IAAI;AAKzD,MAAM,iBAAiB,cAA+B,IAAI;AAC1D,MAAM,qBAA+C,MAAM;AACzD,MAAI;AACF,WACG,YAAmF,4BACpF;AAAA,EAEJ,QAAQ;AACN,WAAO;AAAA,EACT;AACF,GAAG;AAGH,MAAM,qBAA+C,MAAM;AACzD,MAAI;AACF,WACG,YAAmF,4BACpF;AAAA,EAEJ,QAAQ;AACN,WAAO;AAAA,EACT;AACF,GAAG;AAGH,SAAS,OAAO,QAAyB,SAAoC;AAC3E,QAAM,OAAO,OAAO,OAAO,IAAI,UAAU,CAAC;AAC1C,MAAI,OAAO,IAAI,UAAU,EAAG,QAAO,OAAO,SAAS,IAAI,KAAK,OAAO,IAAI,OAAO,IAAI;AAClF,aAAW,SAAS,SAAS;AAC3B,UAAM,QAAQ,OAAO,OAAO,IAAI,KAAK,CAAC;AACtC,QAAI,OAAO,IAAI,KAAK,EAAG,QAAO,OAAO,SAAS,KAAK,KAAK,QAAQ,IAAI,KAAK,MAAM,KAAK,IAAI;AAAA,EAC1F;AACA,SAAO;AACT;AAgBO,SAAS,eAAe,EAAE,QAAQ,QAAQ,MAAM,cAAc,CAAC,EAAE,GAAgC;AACtG,QAAM,WAAW,YAAY;AAC7B,QAAM,WAAW,YAAY;AAC7B,QAAM,EAAE,UAAU,QAAQ,KAAK,IAAI;AACnC,QAAM,UAAU,WAAW,iBAAiB,GAAG;AAC/C,QAAM,aAAa,WAAW,iBAAiB;AAC/C,QAAM,SAAS,gBAAgB;AAC/B,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,UAAU,IAAI,gBAAgB,MAAM,EAAE,IAAI,MAAM;AACtD,QAAM,UAAU,YAAY,KAAK,IAAI;AAErC,QAAM,YAAY,OAAO,MAAM;AAC/B,QAAM,WAAW,OAAkB,QAAQ,SAAS,eAAe;AACnE,QAAM,CAAC,IAAI,IAAI,SAAS,MAAM,WAAuB,aAAa,kBAAkB,CAAC;AACrF,QAAM,cAAc,OAAO,QAAQ;AACnC,kBAAgB,MAAM;AACpB,cAAU,UAAU;AACpB,aAAS,UAAU,QAAQ,SAAS;AACpC,SAAK,IAAI,UAAU,kBAAkB;AACrC,gBAAY,UAAU,SAAS,WAAW;AAAA,EAC5C,CAAC;AAED,QAAM,UAAU,OAAsB,IAAI;AAE1C,QAAM,aAAa,OAAkD,IAAI;AAEzE,QAAM,eAAe,OAAO,KAAK;AAEjC,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,CAAC;AAExC,YAAU,MAAM;AACd,QAAI,YAAY,QAAQ,SAAS;AAC/B,cAAQ,UAAU;AAElB,iBAAW,UACT,WAAW,YAAY,YAAY,UAC/B,EAAE,IAAI,SAAS,YAAY,OAAO,IAAI,gBAAgB,MAAM,GAAG,UAAU,QAAQ,MAAM,IAAI,IAAI,CAAC,CAAC,EAAE,IACnG;AAAA,IACR;AACA,UAAM,UAAU,WAAW;AAC3B,QAAI,CAAC,WAAW,CAAC,MAAO;AACxB,eAAW,UAAU;AACrB,iBAAa,UAAU;AACvB,SAAK,QAAQ,QAAQ,EAClB,KAAK,MAAM,UAAU,QAAQ,QAAQ,IAAI,EAAE,YAAY,QAAQ,WAAW,GAAG,SAAS,SAAS,KAAK,MAAM,CAAC,EAC3G,MAAM,MAAM,MAAS,EACrB,QAAQ,MAAM;AACb,mBAAa,UAAU;AACvB,iBAAW,CAAC,MAAM,IAAI,CAAC;AAAA,IACzB,CAAC;AAAA,EAGL,GAAG,CAAC,SAAS,KAAK,CAAC;AAEnB,YAAU,MAAM;AACd,QAAI,mBAAmB,SAAS,YAAY,EAAE,UAAU,QAAQ,KAAK,CAAC,EAAG;AAEzE,QAAI,WAAW,WAAW,aAAa,QAAS;AAChD,UAAM,SAAS,UAAU,WAAW,WAAW;AAC/C,UAAM,MAAM,CAAC,QAAQ,YAAY,GAAI,UAAU,QAAQ,MAAM,IAAI,IAAI,CAAC,CAAE;AACxE,UAAM,UAAU,IAAI,gBAAgB,MAAM;AAG1C,QAAI,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,QAAQ,QAAQ,IAAI,GAAG,CAAC,EAAG;AACrD,UAAM,OAAO,IAAI,gBAAgB,MAAM;AAGvC,eAAW,OAAO,SAAS,IAAI,MAAM,CAAC,IAAI,IAAK,MAAK,OAAO,GAAG;AAC9D,QAAI,QAAQ;AACV,WAAK,IAAI,QAAQ,MAAM;AACvB,WAAK,IAAI,YAAY,OAAO,QAAQ,CAAC,CAAC;AAAA,IACxC;AACA,QAAI,KAAK,SAAS,MAAM,QAAQ,SAAS,EAAG;AAE5C,SAAK,SAAS,EAAE,UAAU,QAAQ,KAAK,SAAS,GAAG,KAAK,GAAG,EAAE,SAAS,MAAM,OAAO,SAAS,MAAM,CAAC;AAAA,EAErG,GAAG,CAAC,QAAQ,UAAU,OAAO,UAAU,QAAQ,MAAM,SAAS,OAAO,CAAC;AACxE;AAGA,MAAM,kBAA6B,MAAM;AACvC,MAAI,YAAY,EAAG,SAAQ,KAAK,8EAA8E;AAChH;AAEA,MAAM,iBAAiB,MAAM;AAC3B,MAAI,YAAY,EAAG,SAAQ,KAAK,+EAA+E;AACjH;AAEA,MAAM,qBAAiC;AAAA,EACrC,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,UAAU;AAAA,EACV,OAAO;AAAA,EACP,OAAO;AAAA,EACP,UAAU;AAAA,EACV,OAAO;AACT;AACA,MAAM,cAAc,OAAO,KAAK,kBAAkB;AAclD,MAAM,SAAqC,CAAC,OAAO,QAAQ,MAAM;AAQ1D,SAAS,yBACd,SAA6D,CAAC,GACqB;AACnF,QAAM,CAAC,QAAQ,SAAS,IAAI,qBAA0C;AAAA,IACpE,OAAO,EAAE,OAAO,OAAO,SAAS,KAAK,SAAS,IAAI,SAAS,UAAU;AAAA,IACrE,OAAO,EAAE,OAAO,OAAO,SAAS,SAAS,SAAS,MAAM,SAAS,WAAW,OAAO,CAAC,QAAQ,OAAO,OAAU;AAAA,IAC7G,OAAO;AAAA,MACL,OAAO,OAAO,SAAS;AAAA,MACvB,SAAS;AAAA,MACT,SAAS;AAAA,MACT,OAAO,CAAC,QAAQ,OAAO,KAAK,CAAC,MAAM,MAAM,GAAG;AAAA,IAC9C;AAAA,EACF,CAAC;AAED,SAAO,EAAE,GAAG,QAAQ,UAAU,UAAU;AAC1C;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
|
|
6
6
|
"_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
|
|
@@ -8,6 +8,7 @@ import { AccountStateChip, RoleChip, dateColumn } from "../components/account-ch
|
|
|
8
8
|
import type { AccountState, RoleVocabulary } from "../components/account-chips";
|
|
9
9
|
import type { ChipTone } from "../components/chip";
|
|
10
10
|
import type { DataTableColumn } from "../components/data-table";
|
|
11
|
+
import { SelectWidthFloor } from "../components/field-base";
|
|
11
12
|
import { encodeSorts } from "../components/data-table-sort";
|
|
12
13
|
import type { SortState } from "../components/data-table-sort";
|
|
13
14
|
import { RowActions } from "../components/row-actions";
|
|
@@ -298,6 +299,13 @@ function rolesOf<T extends UserRosterRow, R extends string>(
|
|
|
298
299
|
return typeof raw === "string" ? [raw as R] : [...(raw as readonly R[])];
|
|
299
300
|
}
|
|
300
301
|
|
|
302
|
+
/** What a row's role select can show as its value: every role's word, and a held key
|
|
303
|
+
* the vocabulary does not know (RoleSelect shows it as the key). */
|
|
304
|
+
function roleWordsOf<R extends string>(roles: RoleVocabulary<R>, held: R | undefined): string[] {
|
|
305
|
+
const words = (Object.keys(roles) as R[]).map((role) => roles[role].label);
|
|
306
|
+
return held !== undefined && !(held in roles) ? [...words, held] : words;
|
|
307
|
+
}
|
|
308
|
+
|
|
301
309
|
/** The row's name in the reader's order — a component, because the order is the
|
|
302
310
|
* READER's and the locale comes from the provider. */
|
|
303
311
|
function RowName<T extends UserRosterRow>({ row, children }: { row: T; children: (name: string) => ReactNode }) {
|
|
@@ -423,16 +431,28 @@ export function userRosterColumns<T extends UserRosterRow, R extends string = st
|
|
|
423
431
|
return (
|
|
424
432
|
<RowName row={row}>
|
|
425
433
|
{(name) => (
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
434
|
+
// The column keeps room for the select's longest ROLE word in the app's
|
|
435
|
+
// language (0.51, Kurvenschmiede N5): beside the `grow` Name every other
|
|
436
|
+
// column is squeezed to its min-content, which a `w-full` select does not
|
|
437
|
+
// have, so the role select was the header's ~92px and read "Meml…",
|
|
438
|
+
// "Admi…". Every word of the vocabulary, not only the row's offered
|
|
439
|
+
// ones: the column is as wide as its widest row, so the rows line up, and
|
|
440
|
+
// a role held but not offered (KS's Reviewer) is still a selected value.
|
|
441
|
+
// Not the options' reasons ("Customer (owns work)"): they are never the
|
|
442
|
+
// selected value, and the open list is as wide as it needs anyway.
|
|
443
|
+
<div>
|
|
444
|
+
<RoleSelect<R>
|
|
445
|
+
size="sm"
|
|
446
|
+
name={name}
|
|
447
|
+
value={held[0] ?? null}
|
|
448
|
+
roles={roles}
|
|
449
|
+
offered={rules.offered}
|
|
450
|
+
disabledReason={rules.disabledReason}
|
|
451
|
+
optionDisabledReason={rules.optionDisabledReason}
|
|
452
|
+
onChange={(role) => onRoleChange(row, role)}
|
|
453
|
+
/>
|
|
454
|
+
<SelectWidthFloor size="sm" values={roleWordsOf(roles, held[0])} />
|
|
455
|
+
</div>
|
|
436
456
|
)}
|
|
437
457
|
</RowName>
|
|
438
458
|
);
|
|
@@ -364,6 +364,10 @@ export interface EmailVerificationBannerProps {
|
|
|
364
364
|
* of a 390px phone). The resend's outcome ("Sent", the error) then moves out of the
|
|
365
365
|
* message to sit after "Send again", at every width, so the line that answers the press
|
|
366
366
|
* is never hidden behind "More". Default `false`: the outcome stays in the message.
|
|
367
|
+
*
|
|
368
|
+
* 0.51 (kastlan K1, KX-5): in the `strip` variant "Send again" then stays on the clamped
|
|
369
|
+
* message's line on a phone — one line, 43px, on a 390px phone in all seven catalogues —
|
|
370
|
+
* where it took a line of its own (71px). The box's equivalent is `actionPlacement`.
|
|
367
371
|
*/
|
|
368
372
|
clampMobile?: boolean;
|
|
369
373
|
/**
|
|
@@ -206,6 +206,15 @@ interface AlertBannerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "on
|
|
|
206
206
|
* every phone screen, and the app built the clamp and the button around the banner's
|
|
207
207
|
* children by hand. Ignored on a whole-row banner (`href` / `onClick`): a toggle cannot
|
|
208
208
|
* sit inside the row's own link or button.
|
|
209
|
+
*
|
|
210
|
+
* 0.51 (kastlan K1): on a `strip` the clamped message claims 7rem instead of 12rem below
|
|
211
|
+
* `md`, so the {@link action} stays on its line rather than taking one of its own.
|
|
212
|
+
*
|
|
213
|
+
* For a one-line MESSAGE (a sentence or two). Not for a body with a list or a control in
|
|
214
|
+
* it: the clamp cuts the box to its first line, so a Disclosure's or a link's trigger
|
|
215
|
+
* past it is clipped out of sight and still takes the keyboard's focus, and "More" gives
|
|
216
|
+
* back the whole body at once. Shorten such a banner instead — one sentence, its action,
|
|
217
|
+
* and the rest behind a Disclosure of its own (keksdose's agree-again notice, 0.51).
|
|
209
218
|
*/
|
|
210
219
|
clampMobile?: boolean;
|
|
211
220
|
/**
|
|
@@ -300,7 +309,7 @@ export function AlertBanner({
|
|
|
300
309
|
// server) the box is 0 tall, which reads as "may not fit": the toggle stays.
|
|
301
310
|
const clamp = clampMobile && !interactive;
|
|
302
311
|
const bodyId = useId();
|
|
303
|
-
const bodyRef = useRef<
|
|
312
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
304
313
|
const [expanded, setExpanded] = useState(false);
|
|
305
314
|
const [fits, setFits] = useState(false);
|
|
306
315
|
useLayoutEffect(() => {
|
|
@@ -357,6 +366,17 @@ export function AlertBanner({
|
|
|
357
366
|
// into a three-word column on a phone instead. With a real basis the action drops to
|
|
358
367
|
// a second line once message + action no longer fit, and the message keeps the width.
|
|
359
368
|
const grow = strip ? "flex-[1_1_12rem]" : "flex-1";
|
|
369
|
+
// …except a CLAMPED strip on a phone (0.51, kastlan K1 / prod #22, KX-5). There the
|
|
370
|
+
// message is one line whatever its width (the rest is behind "More"), so the 12rem only
|
|
371
|
+
// pushed the action under it: "Erneut senden" took a line of its own on a 390px phone,
|
|
372
|
+
// and kastlan's banner stood 71px where one line is 43. The clamp needs its "More" and
|
|
373
|
+
// the first words beside it, so below `md` the basis is 7rem (~4rem of text). Measured
|
|
374
|
+
// in Chromium at 390 × 844, kastlan's `mx-4` strip (326px inside): one line, 43px, in
|
|
375
|
+
// all seven catalogues, with Roboto and with the wider DejaVu Sans; French, the longest
|
|
376
|
+
// "Send again", keeps 8px. At 360px French (and Spanish in DejaVu) still drop a line.
|
|
377
|
+
// A rem basis like the 12rem, so at Large the action drops a line sooner, by itself.
|
|
378
|
+
// From `md` up the clamp is off and the 12rem holds; without `clampMobile` nothing moves.
|
|
379
|
+
const clampBasis = strip && clamp ? "max-md:basis-28" : undefined;
|
|
360
380
|
// Inside a whole-row banner's button or link, which then carries `grow` itself.
|
|
361
381
|
const nested = interactive && (onDismiss !== undefined || (action !== undefined && action !== null));
|
|
362
382
|
const frame = inline
|
|
@@ -367,25 +387,32 @@ export function AlertBanner({
|
|
|
367
387
|
);
|
|
368
388
|
const focusRing = cn(FOCUS_RING, "focus-visible:ring-offset-1 focus-visible:ring-offset-surface");
|
|
369
389
|
|
|
390
|
+
// The message's box: a `<div>` (0.51), so a banner may hold block content — keksdose's
|
|
391
|
+
// agree-again notice has a list and a Disclosure in it, which inside the `<span>` this was
|
|
392
|
+
// were block elements in phrasing content. A `<span>` still in a whole-row banner, whose
|
|
393
|
+
// `<a>` / `<button>` takes phrasing content only.
|
|
394
|
+
const Body = interactive ? "span" : "div";
|
|
370
395
|
const contentBody = clamp ? (
|
|
371
396
|
// Below `md` a row: the message, clamped, then the toggle on its baseline. From
|
|
372
|
-
// `md` up a plain
|
|
373
|
-
|
|
397
|
+
// `md` up a plain box with the message in it, as without the prop. (Never a whole-row
|
|
398
|
+
// banner, so always a `<div>`.)
|
|
399
|
+
<div
|
|
374
400
|
className={cn(
|
|
375
401
|
!inlineRow && grow,
|
|
402
|
+
clampBasis,
|
|
376
403
|
"min-w-0 text-start max-md:flex max-md:items-baseline max-md:gap-2",
|
|
377
404
|
)}
|
|
378
405
|
>
|
|
379
406
|
{/* The clamp is deliberate: the rest is one tap away behind More, so the size sweep's
|
|
380
407
|
truncation gate (scripts/screenshot-sizes.mjs) leaves it alone. */}
|
|
381
|
-
<
|
|
408
|
+
<div
|
|
382
409
|
id={bodyId}
|
|
383
410
|
ref={bodyRef}
|
|
384
411
|
data-truncate-ok=""
|
|
385
412
|
className={cn("max-md:min-w-0", !expanded && "max-md:line-clamp-1")}
|
|
386
413
|
>
|
|
387
414
|
{children}
|
|
388
|
-
</
|
|
415
|
+
</div>
|
|
389
416
|
{(expanded || !fits) && (
|
|
390
417
|
<button
|
|
391
418
|
type="button"
|
|
@@ -400,11 +427,11 @@ export function AlertBanner({
|
|
|
400
427
|
{expanded ? common.showLess : common.showMore}
|
|
401
428
|
</button>
|
|
402
429
|
)}
|
|
403
|
-
</
|
|
430
|
+
</div>
|
|
404
431
|
) : (
|
|
405
|
-
<
|
|
432
|
+
<Body className={cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start")}>
|
|
406
433
|
{children}
|
|
407
|
-
</
|
|
434
|
+
</Body>
|
|
408
435
|
);
|
|
409
436
|
const content = (
|
|
410
437
|
<>
|
|
@@ -480,10 +507,10 @@ export function AlertBanner({
|
|
|
480
507
|
<div className={flowRow} data-action-flow="">
|
|
481
508
|
{/* The glyph rides with the message, on its first line; the two are
|
|
482
509
|
centred against the action as one, as the whole-row button is. */}
|
|
483
|
-
<
|
|
510
|
+
<div className={cn("flex min-w-0 flex-1 items-start", flowBasis, sm ? "gap-1.5" : "gap-2")}>
|
|
484
511
|
{glyph}
|
|
485
512
|
{contentBody}
|
|
486
|
-
</
|
|
513
|
+
</div>
|
|
487
514
|
{trailing}
|
|
488
515
|
</div>
|
|
489
516
|
</>
|
|
@@ -7,6 +7,7 @@ import { Spinner } from "./spinner";
|
|
|
7
7
|
import { useAnchoredPanel } from "../hooks/use-anchored-panel";
|
|
8
8
|
import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
|
|
9
9
|
import { isImeEnter } from "../lib/ime";
|
|
10
|
+
import { isQuietFocus } from "../lib/quiet-focus";
|
|
10
11
|
import { useAnchorDir } from "./use-anchor-dir";
|
|
11
12
|
import {
|
|
12
13
|
ComboboxFieldLabel,
|
|
@@ -268,6 +269,8 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
|
|
|
268
269
|
// either way, so a caller that controls only SOMETIMES (`open={confirming ? false :
|
|
269
270
|
// undefined}`) hands back a field that is exactly as open as focus says it is.
|
|
270
271
|
const open = openProp ?? ownOpen;
|
|
272
|
+
// The field took the tour's quiet focus (§11.8) and has not opened since.
|
|
273
|
+
const quietRef = useRef(false);
|
|
271
274
|
const setOpen = (next: boolean) => {
|
|
272
275
|
setOwnOpen(next);
|
|
273
276
|
if (next !== open) onOpenChange?.(next);
|
|
@@ -410,11 +413,26 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
|
|
|
410
413
|
onFocus?.(e);
|
|
411
414
|
// A locked field takes focus to say why, and opens nothing.
|
|
412
415
|
if (locked) return;
|
|
416
|
+
// The tour's focus on arrival (§11.8) looks nothing up: the list would
|
|
417
|
+
// cover its card (0.50.1). A click, ArrowDown or typing opens it.
|
|
418
|
+
if (isQuietFocus(e.currentTarget)) {
|
|
419
|
+
quietRef.current = true;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
413
422
|
// A seeded field (keksdose's `initialQuery`) is looked up as it is
|
|
414
423
|
// focused; an empty one asks nothing, because of `minChars`.
|
|
415
424
|
setOpen(true);
|
|
416
425
|
}}
|
|
426
|
+
// After a quiet focus, the person's click on the already-focused field
|
|
427
|
+
// fires no `focus`: the click is their "open" instead.
|
|
428
|
+
onClick={(e) => {
|
|
429
|
+
rest.onClick?.(e);
|
|
430
|
+
if (!quietRef.current || locked) return;
|
|
431
|
+
quietRef.current = false;
|
|
432
|
+
setOpen(true);
|
|
433
|
+
}}
|
|
417
434
|
onBlur={(e) => {
|
|
435
|
+
quietRef.current = false;
|
|
418
436
|
onBlur?.(e);
|
|
419
437
|
close();
|
|
420
438
|
}}
|
|
@@ -34,54 +34,7 @@ export type ButtonVariant =
|
|
|
34
34
|
| "secondary"
|
|
35
35
|
| "ghost"
|
|
36
36
|
| "danger"
|
|
37
|
-
| "link"
|
|
38
|
-
/** @deprecated 0.49 — use `default` (same look); removed in 0.51. */
|
|
39
|
-
| "primary"
|
|
40
|
-
/** @deprecated 0.49 — use `accent` (same look); removed in 0.51. */
|
|
41
|
-
| "brand";
|
|
42
|
-
|
|
43
|
-
/** @internal A {@link ButtonVariant} without the deprecated aliases. */
|
|
44
|
-
export type CanonicalButtonVariant = Exclude<ButtonVariant, "primary" | "brand">;
|
|
45
|
-
|
|
46
|
-
/** The deprecated names and what they stand for — see {@link ButtonVariant}. */
|
|
47
|
-
const DEPRECATED_BUTTON_VARIANTS: Readonly<Record<string, CanonicalButtonVariant>> = {
|
|
48
|
-
primary: "default",
|
|
49
|
-
brand: "accent",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
|
|
53
|
-
* production one (the consumer's bundler makes `import.meta.env.DEV` false) and never
|
|
54
|
-
* under vitest. Read per call so a test can switch it on with `vi.stubEnv`. */
|
|
55
|
-
function devWarnings(): boolean {
|
|
56
|
-
return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const warnedVariants = new Set<string>();
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* @internal A deprecated variant name warns once (per `component` and name) in a
|
|
63
|
-
* development build, then reads as the name it stands for. `component` is the prop's
|
|
64
|
-
* owner in the message ("Button variant", "FormActions submitVariant", …).
|
|
65
|
-
*/
|
|
66
|
-
export function warnDeprecatedVariant(component: string, name: string, replacement: string): void {
|
|
67
|
-
if (!devWarnings()) return;
|
|
68
|
-
const key = `${component}|${name}`;
|
|
69
|
-
if (warnedVariants.has(key)) return;
|
|
70
|
-
warnedVariants.add(key);
|
|
71
|
-
console.warn(
|
|
72
|
-
`[ui-kit] ${component}="${name}" is deprecated: use "${replacement}" (same look). ` +
|
|
73
|
-
`"${name}" is removed in 0.51.`,
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** @internal `primary` → `default`, `brand` → `accent` (warning once in development);
|
|
78
|
-
* every other variant as it is. Runs before every lookup keyed by the variant. */
|
|
79
|
-
export function resolveButtonVariant(variant: ButtonVariant, component = "Button variant"): CanonicalButtonVariant {
|
|
80
|
-
const replacement = DEPRECATED_BUTTON_VARIANTS[variant];
|
|
81
|
-
if (replacement === undefined) return variant as CanonicalButtonVariant;
|
|
82
|
-
warnDeprecatedVariant(component, variant, replacement);
|
|
83
|
-
return replacement;
|
|
84
|
-
}
|
|
37
|
+
| "link";
|
|
85
38
|
|
|
86
39
|
// Shared base ring for every button-styled element. Kept as a named const so the
|
|
87
40
|
// <Button> component and the {@link buttonClasses} helper draw from one source and
|
|
@@ -155,9 +108,8 @@ export const HOVER_INK = "hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,tra
|
|
|
155
108
|
// outline); `accent` is the solid brand fill for the rare strong CTA; `danger` takes the
|
|
156
109
|
// semantic `--danger` family (destructive semantics), so a consumer can re-point the
|
|
157
110
|
// destructive hue instead of inheriting a hard-coded red. Focus rings follow each
|
|
158
|
-
// variant's own accent: brand for the four neutral ones, danger for `danger`.
|
|
159
|
-
|
|
160
|
-
export const buttonVariantClasses: Record<CanonicalButtonVariant, string> = {
|
|
111
|
+
// variant's own accent: brand for the four neutral ones, danger for `danger`.
|
|
112
|
+
export const buttonVariantClasses: Record<ButtonVariant, string> = {
|
|
161
113
|
default:
|
|
162
114
|
// Hovers onto `--bg-active`, not `--border` (0.33, §12.9): More contrast steps the
|
|
163
115
|
// border until it is a 3:1 LINE, and as a fill under the label that left 2.7:1.
|
|
@@ -223,12 +175,12 @@ const BUTTON_TONES: Record<Exclude<ButtonTone, "default">, string> = {
|
|
|
223
175
|
};
|
|
224
176
|
|
|
225
177
|
/** Only the two transparent variants take every tone; see {@link ButtonTone}. */
|
|
226
|
-
const TONED_VARIANTS = new Set<
|
|
178
|
+
const TONED_VARIANTS = new Set<ButtonVariant>(["link", "ghost"]);
|
|
227
179
|
|
|
228
180
|
// `tone="danger"` on the neutral boxed variants — see {@link ButtonTone}. Each string
|
|
229
181
|
// restates every colour the variant sets (border, fill, text, hover, ring) so none of
|
|
230
182
|
// the neutral ones survives the merge.
|
|
231
|
-
const BUTTON_BOXED_DANGER: Partial<Record<
|
|
183
|
+
const BUTTON_BOXED_DANGER: Partial<Record<ButtonVariant, string>> = {
|
|
232
184
|
// Not boxed, but its hover is a fill: the danger wash under the danger text, as
|
|
233
185
|
// IconButton's danger tone (0.33). On B′'s translucent ink `--danger` fell to 4.2:1
|
|
234
186
|
// over a light page.
|
|
@@ -266,12 +218,11 @@ export interface ButtonClassesOptions {
|
|
|
266
218
|
* compact toolbar links.
|
|
267
219
|
*/
|
|
268
220
|
export function buttonClasses(
|
|
269
|
-
|
|
221
|
+
variant: ButtonVariant = "default",
|
|
270
222
|
classNameOrOptions?: string | ButtonClassesOptions,
|
|
271
223
|
): string {
|
|
272
224
|
const { size = "md", className } =
|
|
273
225
|
typeof classNameOrOptions === "object" ? classNameOrOptions : { className: classNameOrOptions };
|
|
274
|
-
const variant = resolveButtonVariant(given, "buttonClasses variant");
|
|
275
226
|
return cn(
|
|
276
227
|
BUTTON_BASE,
|
|
277
228
|
BUTTON_SIZES[size],
|
|
@@ -291,8 +242,7 @@ export const ICON_BUTTON_PRESSED =
|
|
|
291
242
|
"bg-brand-soft text-brand hover:bg-brand-soft-hover hover:text-brand-muted disabled:hover:bg-brand-soft disabled:hover:text-brand";
|
|
292
243
|
|
|
293
244
|
/** @internal The button look shared by the `<button>` and the `<a>` form. */
|
|
294
|
-
export function buttonLook(
|
|
295
|
-
const variant = resolveButtonVariant(given);
|
|
245
|
+
export function buttonLook(variant: ButtonVariant, size: ButtonSize, stretch: boolean | undefined, tone: ButtonTone) {
|
|
296
246
|
return cn(
|
|
297
247
|
BUTTON_BASE,
|
|
298
248
|
BUTTON_SIZES[size],
|
|
@@ -6,6 +6,7 @@ import { cn } from "../lib/cn";
|
|
|
6
6
|
import { useDropdown } from "./dropdown";
|
|
7
7
|
import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
|
|
8
8
|
import { isImeEnter } from "../lib/ime";
|
|
9
|
+
import { isQuietFocus } from "../lib/quiet-focus";
|
|
9
10
|
import { usePhoneLayout } from "../hooks/use-breakpoint";
|
|
10
11
|
import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
|
|
11
12
|
import { ComboboxFieldLabel, endHintRowProps, GROUP_HEADING_STICKY, useActiveOptionScroll } from "./combobox-core";
|
|
@@ -421,11 +422,14 @@ export function Combobox(props: ComboboxProps) {
|
|
|
421
422
|
autoFocus={autoFocus}
|
|
422
423
|
onBlur={onBlur}
|
|
423
424
|
onMouseDown={primaryOnly.onMouseDown}
|
|
424
|
-
onFocus={() => {
|
|
425
|
+
onFocus={(e) => {
|
|
425
426
|
// A back/forward mouse button focuses this field on its way to
|
|
426
427
|
// navigating; it is not a request to open anything (live #309 rework).
|
|
427
428
|
// A locked field takes focus to say why, and opens nothing.
|
|
428
429
|
if (primaryOnly.fromAuxButton() || locked) return;
|
|
430
|
+
// The tour's focus on arrival (§11.8) opens nothing: the list would cover
|
|
431
|
+
// its card (0.50.1). The click handler below still opens on a click.
|
|
432
|
+
if (isQuietFocus(e.currentTarget)) return;
|
|
429
433
|
setOpen(true);
|
|
430
434
|
setTyping(false);
|
|
431
435
|
// The sheet carries its own input, so the field behind it must not also
|
|
@@ -183,7 +183,10 @@ export function useColumnFit<T>({
|
|
|
183
183
|
// update limit (#185). So a column whose return the next measure undid, at the same
|
|
184
184
|
// scroller width, waits for more room than it had then: a wider scroller or narrower
|
|
185
185
|
// content. Whatever the error in its width, that is one invisible flip per room it is
|
|
186
|
-
// tried at.
|
|
186
|
+
// tried at. (0.51, keksdose N2: under `virtualize` that excess was the table coming
|
|
187
|
+
// back at an OLD lock's ratcheted widths, not the ones measured here; the lock now goes
|
|
188
|
+
// when the columns change, data-table-virtual.ts. The guard stays for the rest: a
|
|
189
|
+
// column whose drawn cells are wider than its header.)
|
|
187
190
|
let n = current.length;
|
|
188
191
|
const rooms = new Map<string, number>();
|
|
189
192
|
while (n > 0) {
|
|
@@ -30,7 +30,9 @@ import type { RefObject } from "react";
|
|
|
30
30
|
// nothing). Re-measured from scratch when the data, the columns, a user width, the text
|
|
31
31
|
// size or the scroller's width change — and when a grown column would push the table
|
|
32
32
|
// past its scroller, which it fit before. A `grow` column and a user-resized one are not
|
|
33
|
-
// locked (the first takes the rest, the second is pinned already).
|
|
33
|
+
// locked (the first takes the rest, the second is pinned already). A lock replaced is
|
|
34
|
+
// gone: changing back (a column the fit leaves out and brings back) measures again
|
|
35
|
+
// (0.51, keksdose N2).
|
|
34
36
|
|
|
35
37
|
/** {@link DataTableProps.virtualize}'s options. */
|
|
36
38
|
export interface DataTableVirtualize {
|
|
@@ -302,6 +304,17 @@ export function useRowWindow<T>({
|
|
|
302
304
|
);
|
|
303
305
|
|
|
304
306
|
const lockSig = `${resetKey}|${scrollerWidth}|${colKeys.join(",")}`;
|
|
307
|
+
// A lock is the table it was measured on, so it goes as soon as the signature moves on
|
|
308
|
+
// — not merely set aside until the signature comes BACK (0.51, keksdose N2). The fit
|
|
309
|
+
// leaves a column out and, measuring the table afresh without it, brings it back in the
|
|
310
|
+
// same frame, before this hook measures anything: the signature was then the old one
|
|
311
|
+
// again, and the column came back at the widths that had ratcheted up while rows
|
|
312
|
+
// scrolled (a wider cell widens its column once), 80–110px more than the fit had just
|
|
313
|
+
// measured (606 + 93 estimated, 778–812 drawn, at 757), and B2's guard kept it out
|
|
314
|
+
// although it fitted. Dropped here, the table it comes back to is drawn as measured,
|
|
315
|
+
// and the next frame locks that. Adjusted during render, as React's "storing
|
|
316
|
+
// information from previous renders": an effect would draw the stale widths first.
|
|
317
|
+
if (lock !== null && lock.sig !== lockSig) setLock(null);
|
|
305
318
|
const colWidths = lock && lock.sig === lockSig ? lock.widths : null;
|
|
306
319
|
|
|
307
320
|
// What the scroll handler and the frame's measure read: this render's model and lock.
|
|
@@ -21,7 +21,8 @@ import { DEFAULT_CHARACTER_COUNT_LABELS, DEFAULT_PASSWORD_REVEAL_LABELS } from "
|
|
|
21
21
|
* `useFieldError`, every field's.
|
|
22
22
|
*
|
|
23
23
|
* Public through ./ui by name. `FieldGroup`, the counter and the placeholder helpers are
|
|
24
|
-
* the three fields' shared parts (`@internal`)
|
|
24
|
+
* the three fields' shared parts (`@internal`), as are the Select's own classes and its
|
|
25
|
+
* `SelectWidthFloor` (0.51).
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -197,6 +198,57 @@ export function FieldChevron({ className, ...rest }: FieldChevronProps) {
|
|
|
197
198
|
);
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
/** A native dropdown Select's dress: no native arrow, and `pe-9`, the room the
|
|
202
|
+
* {@link FieldChevron} takes. Shared with {@link SelectWidthFloor}. `@internal` */
|
|
203
|
+
export const SELECT_MENU_DRESS = "appearance-none pe-9";
|
|
204
|
+
|
|
205
|
+
/** The compact Select (`size="sm"`): the field's colours, a toolbar button's height.
|
|
206
|
+
* `py-0` and a fixed height rather than a smaller padding, so the box is 28px whatever
|
|
207
|
+
* line height the caller's type brings with it. Shared with {@link SelectWidthFloor}.
|
|
208
|
+
* `@internal` */
|
|
209
|
+
export const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The width an UNLABELLED Select needs to show the widest of `values` whole: an invisible,
|
|
213
|
+
* zero-height line under it, in the select's own type, padding, chevron room and border
|
|
214
|
+
* (the same classes), one value per line. Placed beside the Select in the box that sizes
|
|
215
|
+
* it, it holds that box to the widest value, and the `w-full` select fills it. `@internal`
|
|
216
|
+
*
|
|
217
|
+
* Why it takes a sizer (0.51, Kurvenschmiede N5). A `<select>` with a percentage width
|
|
218
|
+
* is a "compressible" element: its min-content contribution is ZERO (CSS Sizing 3
|
|
219
|
+
* §5.2.2), so the auto table layout squeezes its column to the header, and beside a
|
|
220
|
+
* `grow` column every other column IS squeezed to its min-content (data-table-fit.ts).
|
|
221
|
+
* The user roster's role select stood 92px and read "Meml…" and "Admi…" (KS review,
|
|
222
|
+
* 0.49 and 0.50). `min-width: max-content` on the select would hold it instead — but to
|
|
223
|
+
* its widest OPTION, and an unavailable role's option carries its reason ("Customer (owns
|
|
224
|
+
* work)", RoleSelect's `optionDisabledReason`): the column would widen for a text that is
|
|
225
|
+
* never the selected value. So the caller says which values can be selected, in the
|
|
226
|
+
* current language.
|
|
227
|
+
*/
|
|
228
|
+
export function SelectWidthFloor({ values, size = "md" }: { values: readonly string[]; size?: "sm" | "md" }) {
|
|
229
|
+
if (values.length === 0) return null;
|
|
230
|
+
return (
|
|
231
|
+
<span
|
|
232
|
+
aria-hidden
|
|
233
|
+
data-select-floor=""
|
|
234
|
+
className={cn(
|
|
235
|
+
FIELD_BASE,
|
|
236
|
+
SELECT_MENU_DRESS,
|
|
237
|
+
size === "sm" && SELECT_SM,
|
|
238
|
+
// The horizontal box only: no height, no vertical padding or border, not painted.
|
|
239
|
+
"invisible h-0 overflow-hidden border-y-0 py-0 whitespace-nowrap shadow-none",
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{values.map((value, i) => (
|
|
243
|
+
// The value as generated content, not as text: it is laid out (and so measured)
|
|
244
|
+
// the same, but a test's `getByText("Member")` still finds the one option, and a
|
|
245
|
+
// find-in-page or a copied row does not meet a word nobody sees.
|
|
246
|
+
<span key={i} data-value={value} className="block before:content-[attr(data-value)]" />
|
|
247
|
+
))}
|
|
248
|
+
</span>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
200
252
|
// Animated label that starts centred (as a placeholder) in an empty field and
|
|
201
253
|
// floats up INSIDE the top strip on focus or once the field has a value. Sits on
|
|
202
254
|
// the field's own surface, so no background chip and nothing to mismatch the card.
|