@eifi1/ui-kit 0.43.0 → 0.45.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 +11 -7
- package/dist/admin/admin-action-log-labels.js +9 -0
- package/dist/admin/admin-action-log-labels.js.map +1 -1
- package/dist/admin/admin-action-log.d.ts +18 -1
- package/dist/admin/admin-action-log.js +25 -16
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.d.ts +6 -0
- package/dist/admin/admin-area-labels.js +1 -0
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/mail-test-card-labels.d.ts +3 -0
- package/dist/admin/mail-test-card-labels.js +17 -0
- package/dist/admin/mail-test-card-labels.js.map +1 -0
- package/dist/admin/mail-test-card.d.ts +74 -0
- package/dist/admin/mail-test-card.js +80 -0
- package/dist/admin/mail-test-card.js.map +1 -0
- package/dist/billing/provider-page.d.ts +9 -0
- package/dist/billing/provider-page.js.map +1 -1
- package/dist/components/data-table-mobile.js +17 -2
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-shared.d.ts +2 -0
- package/dist/components/data-table-shared.js +8 -0
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.js +1 -6
- package/dist/components/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +42 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +13 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +60 -4
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +40 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +42 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +40 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +41 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +40 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +13 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts +2 -1
- package/dist/lib/format.js +6 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
- package/dist/offline/pwa-update-prompt-labels.js +9 -0
- package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
- package/dist/offline/pwa-update-prompt.d.ts +32 -0
- package/dist/offline/pwa-update-prompt.js +36 -0
- package/dist/offline/pwa-update-prompt.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +100 -0
- package/dist/offline/query-persistence.js +179 -0
- package/dist/offline/query-persistence.js.map +1 -0
- package/dist/offline/shared-computer-labels.d.ts +2 -0
- package/dist/offline/shared-computer-labels.js +9 -0
- package/dist/offline/shared-computer-labels.js.map +1 -0
- package/dist/offline/shared-computer.d.ts +44 -0
- package/dist/offline/shared-computer.js +51 -0
- package/dist/offline/shared-computer.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
- package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
- package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
- package/dist/offline/sign-out-pending-dialog.js +66 -0
- package/dist/offline/sign-out-pending-dialog.js.map +1 -0
- package/dist/offline.d.ts +8 -0
- package/dist/offline.js +24 -0
- package/dist/offline.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +24 -0
- package/dist/shell/admin-menu.js +12 -3
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.js +2 -2
- package/dist/tour/tour-catalogue-labels.js.map +1 -1
- package/dist/tour/tour-catalogue.d.ts +7 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- package/dist/tour/tour-info.d.ts +2 -2
- package/dist/tour/tour-info.js.map +1 -1
- package/dist/tour/tour.d.ts +38 -2
- package/dist/tour/tour.js +42 -3
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +165 -12
- package/dist/tour/use-tour-progress.js +271 -25
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour.d.ts +3 -1
- package/dist/tour.js +4 -1
- package/dist/tour.js.map +1 -1
- package/package.json +25 -1
- package/src/admin/admin-action-log-labels.ts +9 -0
- package/src/admin/admin-action-log.tsx +41 -5
- package/src/admin/admin-area-labels.ts +7 -0
- package/src/admin/mail-test-card-labels.ts +19 -0
- package/src/admin/mail-test-card.tsx +186 -0
- package/src/billing/provider-page.ts +9 -0
- package/src/components/data-table-mobile.tsx +26 -3
- package/src/components/data-table-shared.tsx +18 -1
- package/src/components/data-table.tsx +1 -15
- package/src/i18n/defaults.ts +8 -0
- package/src/i18n/german.ts +48 -2
- package/src/i18n/kit-labels.tsx +13 -0
- package/src/i18n/locales/en.ts +13 -1
- package/src/i18n/locales/es.ts +46 -0
- package/src/i18n/locales/fr.ts +48 -2
- package/src/i18n/locales/hu.ts +42 -0
- package/src/i18n/locales/it.ts +47 -1
- package/src/i18n/locales/zh.ts +40 -0
- package/src/i18n/review.ts +12 -0
- package/src/index.ts +3 -0
- package/src/lib/format.ts +13 -2
- package/src/offline/pwa-update-prompt-labels.ts +9 -0
- package/src/offline/pwa-update-prompt.tsx +72 -0
- package/src/offline/query-persistence.ts +323 -0
- package/src/offline/shared-computer-labels.ts +8 -0
- package/src/offline/shared-computer.tsx +87 -0
- package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
- package/src/offline/sign-out-pending-dialog.tsx +135 -0
- package/src/offline.ts +26 -0
- package/src/shell/admin-menu.tsx +40 -3
- package/src/shell.ts +4 -0
- package/src/tour/tour-catalogue-labels.ts +2 -2
- package/src/tour/tour-catalogue.tsx +7 -1
- package/src/tour/tour-info.ts +2 -2
- package/src/tour/tour.tsx +87 -3
- package/src/tour/use-tour-progress.ts +501 -34
- package/src/tour.ts +5 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/admin/mail-test-card.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Send } from \"lucide-react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { isRateLimited, retryAfterSeconds } from \"../auth/auth-errors\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { Button } from \"../components/button\";\nimport { Chip } from \"../components/chip\";\nimport { SettingsSection, type SettingsSectionProps } from \"../settings/settings-section\";\nimport { DEFAULT_MAIL_TEST_CARD_LABELS } from \"./mail-test-card-labels\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string {@link MailTestCard} renders — the `mailTestCard` namespace of\n * `<UiKitProvider labels>`, overridable per instance through `labels`\n * (docs/admin-harmonization.md §9.2.4). */\nexport interface MailTestCardLabels {\n /** The card's title, and the app's catalogue entry's. */\n title: string;\n /** The sentence under it. */\n description: string;\n /** The transport's chip: \"Transport: resend\". `backend` is the server's `mail_backend`. */\n transport: (backend: string) => string;\n /** The button. */\n action: string;\n /** The answer said \"sent\" through a real transport. `address` is masked (\"a…@example.com\"). */\n sent: (address: string) => string;\n /** `mail_backend` is `console`: said whatever `sent` is — a console answers `true`. */\n console: string;\n /** Not sent, or the request failed. The provider's error stays in the server log. */\n failed: string;\n /** HTTP 429 — the app's limiter (server-kit's `MAIL_TEST_LIMIT`, 3 per 10 minutes);\n * `seconds` from `Retry-After` when the response has one. */\n rateLimited: (seconds?: number) => string;\n /** For the app's catalogue entry (`keywords`); the card never reads it. */\n keywords: string;\n}\n\n// Defined in ./mail-test-card-labels, which holds no component code.\nexport { DEFAULT_MAIL_TEST_CARD_LABELS };\n\n/* ── The card ────────────────────────────────────────────────────────────── */\n\n/** The answer of `POST /admin/mail/test` — server-kit 0.15's `MailTestResult`. */\nexport interface MailTestAnswer {\n sent: boolean;\n /** The transport that took it: `console`, `resend`, `smtp`, … */\n mail_backend: string;\n}\n\n/** The card's default DOM id: its catalogue entry's `anchor`, the `?focus=` target. */\nexport const MAIL_TEST_ANCHOR = \"admin-mail-test\";\n\nexport interface MailTestCardProps\n extends Omit<SettingsSectionProps, \"anchor\" | \"title\" | \"description\" | \"children\"> {\n /** The installation's transport, as the admin page has it (`mail_backend`): the chip\n * before a send. After one, the answer's. */\n mailBackend: string;\n /** The app's `POST /admin/mail/test` (kastlan `/platform/mail/test`), no body: it\n * awaits the app's own send and answers `{sent, mail_backend}`. A rejection is read\n * for a 429 (the limiter); anything else is \"could not send\". */\n send: () => Promise<MailTestAnswer>;\n /** The signed-in admin's own address — where the mail goes. Shown masked. */\n address: string;\n /** Default {@link MAIL_TEST_ANCHOR}; the app's catalogue entry names the same. */\n anchor?: string;\n /** The app's sentence after the kit's description. */\n description?: ReactNode;\n labels?: LabelOverride<MailTestCardLabels>;\n}\n\n/**\n * An address with its local part masked to its first character: \"ada@example.com\" →\n * \"a…@example.com\". The domain stays, so the admin sees which mailbox to open.\n */\nexport function maskMailAddress(address: string): string {\n const text = address.trim();\n const at = text.lastIndexOf(\"@\");\n if (at <= 0) return text === \"\" ? \"\" : `${Array.from(text)[0]}…`;\n return `${Array.from(text.slice(0, at))[0]}…${text.slice(at)}`;\n}\n\ntype Outcome =\n | { kind: \"sent\"; backend: string }\n | { kind: \"console\"; backend: string }\n | { kind: \"failed\"; backend?: string }\n | { kind: \"limited\"; seconds?: number };\n\n/**\n * \"Send a test mail to me\" (docs/admin-harmonization.md §9.2): a `SettingsSection` with a\n * sentence, the transport as a chip, the button and the result line. One mail goes to the\n * signed-in admin's own address, never another, through the app's own send.\n *\n * **The result line reads `mail_backend` first**: with `console` it says \"nothing was sent\"\n * whatever `sent` is, since every console path answers `true` (§9.2 Today). Otherwise\n * `sent` says \"Sent to a…@example.com. Check the inbox.\" or \"Could not send.\" — the\n * provider's error stays in the server log (§9.2.7). A 429 says the limiter's line. The\n * button is busy while the request runs; the line is a live region.\n *\n * Not a commit: the test writes nothing, so it is not under the write lock (a demo never\n * reaches the admin area, and the server refuses it anyway). The app adds the catalogue\n * entry `{ id, anchor: MAIL_TEST_ANCHOR, title, keywords }` from these labels.\n */\nexport function MailTestCard({\n mailBackend,\n send,\n address,\n anchor = MAIL_TEST_ANCHOR,\n description,\n labels: labelsProp,\n ...rest\n}: MailTestCardProps) {\n const labels = useKitLabels(\"mailTestCard\", DEFAULT_MAIL_TEST_CARD_LABELS, labelsProp);\n const [busy, setBusy] = useState(false);\n const [outcome, setOutcome] = useState<Outcome | null>(null);\n\n const run = async () => {\n if (busy) return;\n setBusy(true);\n setOutcome(null);\n try {\n const answer = await send();\n const backend = answer.mail_backend;\n if (backend === \"console\") setOutcome({ kind: \"console\", backend });\n else setOutcome(answer.sent ? { kind: \"sent\", backend } : { kind: \"failed\", backend });\n } catch (error) {\n setOutcome(isRateLimited(error) ? { kind: \"limited\", seconds: retryAfterSeconds(error) } : { kind: \"failed\" });\n } finally {\n setBusy(false);\n }\n };\n\n const backend = outcome && \"backend\" in outcome && outcome.backend ? outcome.backend : mailBackend;\n\n return (\n <SettingsSection\n {...rest}\n anchor={anchor}\n title={labels.title}\n description={\n description != null ? (\n <>\n {labels.description} {description}\n </>\n ) : (\n labels.description\n )\n }\n >\n <div className=\"space-y-2\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button variant=\"secondary\" pending={busy} onClick={() => void run()}>\n <Send className=\"size-4\" aria-hidden />\n {labels.action}\n </Button>\n {backend && (\n <Chip size=\"sm\" data-slot=\"mail-backend\">\n {labels.transport(backend)}\n </Chip>\n )}\n </div>\n {outcome?.kind === \"sent\" && (\n <AlertBanner variant=\"inline\" block tone=\"success\">\n <span data-private>{labels.sent(maskMailAddress(address))}</span>\n </AlertBanner>\n )}\n {outcome?.kind === \"console\" && (\n <AlertBanner variant=\"inline\" block tone=\"info\">\n {labels.console}\n </AlertBanner>\n )}\n {outcome?.kind === \"failed\" && (\n <AlertBanner variant=\"inline\" block tone=\"danger\">\n {labels.failed}\n </AlertBanner>\n )}\n {outcome?.kind === \"limited\" && (\n <AlertBanner variant=\"inline\" block tone=\"warning\">\n {labels.rateLimited(outcome.seconds)}\n </AlertBanner>\n )}\n </div>\n </SettingsSection>\n );\n}\n"],"mappings":";AA8IU,mBAWE,KAXF;AA9IV,SAAS,gBAAgB;AAEzB,SAAS,YAAY;AAErB,SAAS,oBAAwC;AACjD,SAAS,eAAe,yBAAyB;AACjD,SAAS,mBAAmB;AAC5B,SAAS,cAAc;AACvB,SAAS,YAAY;AACrB,SAAS,uBAAkD;AAC3D,SAAS,qCAAqC;AA0CvC,MAAM,mBAAmB;AAwBzB,SAAS,gBAAgB,SAAyB;AACvD,QAAM,OAAO,QAAQ,KAAK;AAC1B,QAAM,KAAK,KAAK,YAAY,GAAG;AAC/B,MAAI,MAAM,EAAG,QAAO,SAAS,KAAK,KAAK,GAAG,MAAM,KAAK,IAAI,EAAE,CAAC,CAAC;AAC7D,SAAO,GAAG,MAAM,KAAK,KAAK,MAAM,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,SAAI,KAAK,MAAM,EAAE,CAAC;AAC9D;AAuBO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,QAAQ;AAAA,EACR,GAAG;AACL,GAAsB;AACpB,QAAM,SAAS,aAAa,gBAAgB,+BAA+B,UAAU;AACrF,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,CAAC,SAAS,UAAU,IAAI,SAAyB,IAAI;AAE3D,QAAM,MAAM,YAAY;AACtB,QAAI,KAAM;AACV,YAAQ,IAAI;AACZ,eAAW,IAAI;AACf,QAAI;AACF,YAAM,SAAS,MAAM,KAAK;AAC1B,YAAMA,WAAU,OAAO;AACvB,UAAIA,aAAY,UAAW,YAAW,EAAE,MAAM,WAAW,SAAAA,SAAQ,CAAC;AAAA,UAC7D,YAAW,OAAO,OAAO,EAAE,MAAM,QAAQ,SAAAA,SAAQ,IAAI,EAAE,MAAM,UAAU,SAAAA,SAAQ,CAAC;AAAA,IACvF,SAAS,OAAO;AACd,iBAAW,cAAc,KAAK,IAAI,EAAE,MAAM,WAAW,SAAS,kBAAkB,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAAA,IAC/G,UAAE;AACA,cAAQ,KAAK;AAAA,IACf;AAAA,EACF;AAEA,QAAM,UAAU,WAAW,aAAa,WAAW,QAAQ,UAAU,QAAQ,UAAU;AAEvF,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ;AAAA,MACA,OAAO,OAAO;AAAA,MACd,aACE,eAAe,OACb,iCACG;AAAA,eAAO;AAAA,QAAY;AAAA,QAAE;AAAA,SACxB,IAEA,OAAO;AAAA,MAIX,+BAAC,SAAI,WAAU,aACb;AAAA,6BAAC,SAAI,WAAU,qCACb;AAAA,+BAAC,UAAO,SAAQ,aAAY,SAAS,MAAM,SAAS,MAAM,KAAK,IAAI,GACjE;AAAA,gCAAC,QAAK,WAAU,UAAS,eAAW,MAAC;AAAA,YACpC,OAAO;AAAA,aACV;AAAA,UACC,WACC,oBAAC,QAAK,MAAK,MAAK,aAAU,gBACvB,iBAAO,UAAU,OAAO,GAC3B;AAAA,WAEJ;AAAA,QACC,SAAS,SAAS,UACjB,oBAAC,eAAY,SAAQ,UAAS,OAAK,MAAC,MAAK,WACvC,8BAAC,UAAK,gBAAY,MAAE,iBAAO,KAAK,gBAAgB,OAAO,CAAC,GAAE,GAC5D;AAAA,QAED,SAAS,SAAS,aACjB,oBAAC,eAAY,SAAQ,UAAS,OAAK,MAAC,MAAK,QACtC,iBAAO,SACV;AAAA,QAED,SAAS,SAAS,YACjB,oBAAC,eAAY,SAAQ,UAAS,OAAK,MAAC,MAAK,UACtC,iBAAO,QACV;AAAA,QAED,SAAS,SAAS,aACjB,oBAAC,eAAY,SAAQ,UAAS,OAAK,MAAC,MAAK,WACtC,iBAAO,YAAY,QAAQ,OAAO,GACrC;AAAA,SAEJ;AAAA;AAAA,EACF;AAEJ;","names":["backend"]}
|
|
@@ -36,5 +36,14 @@ export interface OpenProviderPageOptions {
|
|
|
36
36
|
* />
|
|
37
37
|
*
|
|
38
38
|
* Resolves with the way it went.
|
|
39
|
+
*
|
|
40
|
+
* **The test seam is `options.window`** (docs/tours-harmonization.md §9.5, kk 7): an app's
|
|
41
|
+
* test passes a stub with `open` (returning a fake tab, or `null` for a blocked pop-up)
|
|
42
|
+
* and `location.assign`, instead of patching the global `window`:
|
|
43
|
+
*
|
|
44
|
+
* const tab = { opener: {}, closed: false, location: { href: "" }, close: vi.fn() };
|
|
45
|
+
* const win = { open: vi.fn(() => tab), location: { assign: vi.fn() } } as unknown as Window;
|
|
46
|
+
* await openProviderPage(async () => "https://pay.example/portal", { window: win });
|
|
47
|
+
* expect(tab.location.href).toBe("https://pay.example/portal");
|
|
39
48
|
*/
|
|
40
49
|
export declare function openProviderPage(fetchUrl: () => Promise<string>, options?: OpenProviderPageOptions): Promise<ProviderPageWay>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/billing/provider-page.ts"],"sourcesContent":["/**\n * The provider's hosted portal in a NEW TAB (0.43, docs/billing-harmonization.md §17):\n * \"Payment and invoices\", the portal's \"Cancel subscription\" and the payment-failed\n * banner's \"Update payment method\" open at the provider while the app stays open where\n * it was. kastlan's `usePortal` (feat/kit-0.42, `72c3177`) is the reference.\n *\n * The checkout is NOT one of them (§17.4): it stays in the same tab, since the page waits\n * for the payer's return (`useCheckoutProcessing`).\n */\n\n/** How the page went: in a tab of its own, or — the browser refused one — in this one. */\nexport type ProviderPageWay = \"new-tab\" | \"same-tab\";\n\nexport interface OpenProviderPageOptions {\n /** The window to open from and to fall back to. Default the global `window` (a test's\n * or an iframe's own). */\n window?: Window;\n}\n\n/**\n * Opens the provider's page in a new tab, from INSIDE the click: call it in the click\n * handler itself, before any `await` — a `window.open` after one is blocked as a pop-up.\n *\n * 1. opens `about:blank` synchronously and sets its `opener` to `null`, so the provider's\n * page cannot reach back into the app;\n * 2. awaits `fetchUrl` — the app's `POST /billing/portal` with its target, answering the\n * `url` (the kit sends no request);\n * 3. points the tab at the URL;\n * 4. on a refusal closes the tab again and rethrows, so the app shows its error;\n * 5. where the browser refused the tab (a pop-up blocker), or it was closed while the\n * URL was on its way, the app's own page goes there instead.\n *\n * <SubscriptionActions\n * onPortal={(target) => {\n * openProviderPage(() => api.billingPortal({ target }).then((r) => r.url)).catch(showError);\n * }}\n * providerName=\"Paddle\"\n * appName=\"Kastlan\"\n * />\n *\n * Resolves with the way it went.\n */\nexport async function openProviderPage(\n fetchUrl: () => Promise<string>,\n options: OpenProviderPageOptions = {},\n): Promise<ProviderPageWay> {\n const win = options.window ?? window;\n let tab: Window | null;\n try {\n tab = win.open(\"about:blank\", \"_blank\");\n } catch {\n tab = null;\n }\n if (tab) {\n try {\n // The provider's page must not reach back into the app.\n tab.opener = null;\n } catch {\n /* a browser that guards `opener`: the tab is still ours to point */\n }\n }\n let url: string;\n try {\n url = await fetchUrl();\n } catch (error) {\n tab?.close();\n throw error;\n }\n if (tab && !tab.closed) {\n tab.location.href = url;\n return \"new-tab\";\n }\n win.location.assign(url);\n return \"same-tab\";\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/billing/provider-page.ts"],"sourcesContent":["/**\n * The provider's hosted portal in a NEW TAB (0.43, docs/billing-harmonization.md §17):\n * \"Payment and invoices\", the portal's \"Cancel subscription\" and the payment-failed\n * banner's \"Update payment method\" open at the provider while the app stays open where\n * it was. kastlan's `usePortal` (feat/kit-0.42, `72c3177`) is the reference.\n *\n * The checkout is NOT one of them (§17.4): it stays in the same tab, since the page waits\n * for the payer's return (`useCheckoutProcessing`).\n */\n\n/** How the page went: in a tab of its own, or — the browser refused one — in this one. */\nexport type ProviderPageWay = \"new-tab\" | \"same-tab\";\n\nexport interface OpenProviderPageOptions {\n /** The window to open from and to fall back to. Default the global `window` (a test's\n * or an iframe's own). */\n window?: Window;\n}\n\n/**\n * Opens the provider's page in a new tab, from INSIDE the click: call it in the click\n * handler itself, before any `await` — a `window.open` after one is blocked as a pop-up.\n *\n * 1. opens `about:blank` synchronously and sets its `opener` to `null`, so the provider's\n * page cannot reach back into the app;\n * 2. awaits `fetchUrl` — the app's `POST /billing/portal` with its target, answering the\n * `url` (the kit sends no request);\n * 3. points the tab at the URL;\n * 4. on a refusal closes the tab again and rethrows, so the app shows its error;\n * 5. where the browser refused the tab (a pop-up blocker), or it was closed while the\n * URL was on its way, the app's own page goes there instead.\n *\n * <SubscriptionActions\n * onPortal={(target) => {\n * openProviderPage(() => api.billingPortal({ target }).then((r) => r.url)).catch(showError);\n * }}\n * providerName=\"Paddle\"\n * appName=\"Kastlan\"\n * />\n *\n * Resolves with the way it went.\n *\n * **The test seam is `options.window`** (docs/tours-harmonization.md §9.5, kk 7): an app's\n * test passes a stub with `open` (returning a fake tab, or `null` for a blocked pop-up)\n * and `location.assign`, instead of patching the global `window`:\n *\n * const tab = { opener: {}, closed: false, location: { href: \"\" }, close: vi.fn() };\n * const win = { open: vi.fn(() => tab), location: { assign: vi.fn() } } as unknown as Window;\n * await openProviderPage(async () => \"https://pay.example/portal\", { window: win });\n * expect(tab.location.href).toBe(\"https://pay.example/portal\");\n */\nexport async function openProviderPage(\n fetchUrl: () => Promise<string>,\n options: OpenProviderPageOptions = {},\n): Promise<ProviderPageWay> {\n const win = options.window ?? window;\n let tab: Window | null;\n try {\n tab = win.open(\"about:blank\", \"_blank\");\n } catch {\n tab = null;\n }\n if (tab) {\n try {\n // The provider's page must not reach back into the app.\n tab.opener = null;\n } catch {\n /* a browser that guards `opener`: the tab is still ours to point */\n }\n }\n let url: string;\n try {\n url = await fetchUrl();\n } catch (error) {\n tab?.close();\n throw error;\n }\n if (tab && !tab.closed) {\n tab.location.href = url;\n return \"new-tab\";\n }\n win.location.assign(url);\n return \"same-tab\";\n}\n"],"mappings":";AAmDA,eAAsB,iBACpB,UACA,UAAmC,CAAC,GACV;AAC1B,QAAM,MAAM,QAAQ,UAAU;AAC9B,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,KAAK,eAAe,QAAQ;AAAA,EACxC,QAAQ;AACN,UAAM;AAAA,EACR;AACA,MAAI,KAAK;AACP,QAAI;AAEF,UAAI,SAAS;AAAA,IACf,QAAQ;AAAA,IAER;AAAA,EACF;AACA,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,SAAS;AAAA,EACvB,SAAS,OAAO;AACd,SAAK,MAAM;AACX,UAAM;AAAA,EACR;AACA,MAAI,OAAO,CAAC,IAAI,QAAQ;AACtB,QAAI,SAAS,OAAO;AACpB,WAAO;AAAA,EACT;AACA,MAAI,SAAS,OAAO,GAAG;AACvB,SAAO;AACT;","names":[]}
|
|
@@ -13,7 +13,7 @@ import { SwipeableRow } from "./swipeable-row.js";
|
|
|
13
13
|
import { DialogFrame } from "./dialog-frame.js";
|
|
14
14
|
import { Checkbox } from "./checkbox.js";
|
|
15
15
|
import { useLongPress } from "../hooks/use-long-press.js";
|
|
16
|
-
import { RowLink, cleanAttrs, columnLabel, fromOutside } from "./data-table-shared.js";
|
|
16
|
+
import { RowLink, cleanAttrs, columnLabel, fromOutside, fromOwnControl } from "./data-table-shared.js";
|
|
17
17
|
function dataAttrsOf(props) {
|
|
18
18
|
if (!props) return void 0;
|
|
19
19
|
const out = {};
|
|
@@ -225,7 +225,20 @@ function MobileRow({
|
|
|
225
225
|
}
|
|
226
226
|
))
|
|
227
227
|
] });
|
|
228
|
-
const press = select ?
|
|
228
|
+
const press = select ? {
|
|
229
|
+
...longPress.handlers,
|
|
230
|
+
onPointerDown: (e) => {
|
|
231
|
+
if (fromOutside(e.target, e.currentTarget) || fromOwnControl(e.target, e.currentTarget)) {
|
|
232
|
+
longPress.consumeClick();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
longPress.handlers.onPointerDown(e);
|
|
236
|
+
},
|
|
237
|
+
onContextMenu: (e) => {
|
|
238
|
+
if (fromOutside(e.target, e.currentTarget) || fromOwnControl(e.target, e.currentTarget)) return;
|
|
239
|
+
longPress.handlers.onContextMenu(e);
|
|
240
|
+
}
|
|
241
|
+
} : void 0;
|
|
229
242
|
const body = selecting ? (
|
|
230
243
|
// In the mode a card is a toggle — kk's register row: `role="button"` with
|
|
231
244
|
// `aria-pressed`, the box beside it only a picture of that state (the card is the
|
|
@@ -243,6 +256,7 @@ function MobileRow({
|
|
|
243
256
|
...press,
|
|
244
257
|
onClick: (e) => {
|
|
245
258
|
if (!select.selectable || fromOutside(e.target, e.currentTarget)) return;
|
|
259
|
+
if (fromOwnControl(e.target, e.currentTarget)) return;
|
|
246
260
|
select.onToggle(!select.selected);
|
|
247
261
|
},
|
|
248
262
|
onKeyDown: (e) => {
|
|
@@ -302,6 +316,7 @@ function MobileRow({
|
|
|
302
316
|
tabIndex: interactive ? 0 : void 0,
|
|
303
317
|
onClick: interactive ? (e) => {
|
|
304
318
|
if (fromOutside(e.target, e.currentTarget)) return;
|
|
319
|
+
if (fromOwnControl(e.target, e.currentTarget)) return;
|
|
305
320
|
onRowClick(row);
|
|
306
321
|
} : void 0,
|
|
307
322
|
onKeyDown: interactive ? (e) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/data-table-mobile.tsx"],"sourcesContent":["import { Fragment, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { ArrowDownWideNarrow, ArrowUpNarrowWide, ChevronDown, ChevronRight, Filter } from \"lucide-react\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { IconButton } from \"./icon-button\";\nimport { Select } from \"./select\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { defaultFilterState, isFilterActive, resolveFilter } from \"./data-table-filters\";\nimport type { ColumnFilter, FilterValue } from \"./data-table-filters\";\nimport type { SortCycle, SortState } from \"./data-table-sort\";\nimport { FilterPopover } from \"./data-table-filter-popover\";\nimport { SwipeableRow, type SwipeAction } from \"./swipeable-row\";\nimport { DialogFrame } from \"./dialog-frame\";\nimport { Checkbox } from \"./checkbox\";\nimport { useLongPress } from \"../hooks/use-long-press\";\nimport type { DataTableLabels } from \"./data-table-labels\";\nimport { RowLink, cleanAttrs, columnLabel, fromOutside } from \"./data-table-shared\";\nimport type {\n DataTableCellProps,\n DataTableColumn,\n DataTableProps,\n DataTableSortLabels,\n FilterState,\n MobileSwipeActions,\n} from \"./data-table\";\n\n// DataTable's phone layout: the card a row becomes, the fields inside it, and the sort\n// and filter controls above the card list. Moved out of data-table.tsx whole (refactor\n// audit 2026-10, MOD-3 step 4); the list itself, its pager and its row dialog stay in\n// DataTable, which owns the state they share with the wide-screen table.\n\n/** Only the `data-*` attributes of a cell's props — what a phone card field takes. */\nfunction dataAttrsOf(props: DataTableCellProps | undefined): Record<string, unknown> | undefined {\n if (!props) return undefined;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(props)) if (k.startsWith(\"data-\") && k !== \"data-col\") out[k] = v;\n return out;\n}\n\n/** The logical sides onto the physical drag directions {@link SwipeableRow} takes. */\nfunction physicalSwipe(\n actions: MobileSwipeActions,\n dir: Direction,\n): { left?: SwipeAction[]; right?: SwipeAction[] } {\n const rtl = dir === \"rtl\";\n return {\n left: (rtl ? actions.end : actions.start) ?? actions.left,\n right: (rtl ? actions.start : actions.end) ?? actions.right,\n };\n}\n\n// ---------- Phone card at Large (0.32) ----------\n\n/**\n * §4 \"nothing truncates\" in a phone card at Large and Extra large: a cell's own\n * `truncate` (a kit cell, a chip, the app's span) wraps instead — and breaks inside a\n * word too long for the card rather than running out of it. A descendant rule, so it\n * reaches the cells without the table knowing what they render; `large:`, so Normal\n * keeps every ellipsis it had.\n */\nconst CARD_NO_TRUNCATE =\n \"large:[&_.truncate]:whitespace-normal large:[&_.truncate]:[overflow-wrap:anywhere]\";\n\n/**\n * Columns as label-above-value pairs: the phone card's fields at Large (§4), and the\n * opened row's `mobileDetailsInRow` at every size. Each pair is a `<div>` in the\n * `<dl>` — the grouping HTML allows — so a pair keeps together and the gap falls\n * BETWEEN facts rather than between a label and its own value. `relative` holds a\n * header's `sr-only` text (an actions column's), as every kit container must.\n */\nexport function StackedFields<T>({ columns, row, compact }: { columns: DataTableColumn<T>[]; row: T; compact: boolean }) {\n return (\n <dl\n data-card-fields=\"stacked\"\n className={cn(\"relative flex min-w-0 flex-col\", compact ? \"gap-1.5 text-xs\" : \"gap-2.5 text-sm\", CARD_NO_TRUNCATE)}\n >\n {columns.map((col) => (\n <div key={col.key} className=\"min-w-0\">\n <dt className=\"text-xs uppercase tracking-wide text-muted\">{col.header}</dt>\n <dd\n {...dataAttrsOf(col.cellProps?.(row))}\n className=\"mt-0.5 min-w-0 break-words text-secondary\"\n >\n {col.cell(row)}\n </dd>\n </div>\n ))}\n </dl>\n );\n}\n\n// ---------- Phone sort control (keksdose K10) ----------\n\n/**\n * The card list's sort control: a \"Sort by\" select and a direction button.\n *\n * keksdose K10 (inputs audit 2026-10-02 §5; its aggregated report tables, #258): a\n * table is sorted by its header row and the phone card list has none, so the two\n * decisions the header carries — which column, which direction — were unreachable\n * below the breakpoint. keksdose hand-built this row over its DataTable to answer\n * \"who did I pay the most\" on the device it was asked on; this is that row, driven by\n * the SAME sort state as the headers (controlled `sorts` included), so turning the\n * phone sideways keeps the order and a sort chosen here is the one the URL and the\n * storage key hold.\n *\n * The rules are the header's: a newly picked column starts in its `firstSort`, the\n * direction button flips the primary sort (secondary ones stay), and \"Default order\"\n * — the third click of a `\"tri\"` cycle — is offered only under `\"tri\"`, since a\n * `\"toggle\"` table promises it is always ranked by something. The select's label is\n * a real `<label>` beside it, and the button sits outside that label: a click inside\n * a label is forwarded to the control it names, so a nested button would flip AND\n * open the select.\n */\nexport function MobileSort<T>({\n columns,\n sorts,\n sortCycle,\n compact,\n labels,\n onPick,\n onFlip,\n}: {\n columns: DataTableColumn<T>[];\n sorts: SortState[];\n sortCycle: SortCycle;\n compact: boolean;\n labels: DataTableLabels;\n onPick: (key: string | null) => void;\n onFlip: () => void;\n}) {\n const selectId = useId();\n const sortLabels: DataTableSortLabels = labels;\n if (columns.length === 0) return null;\n // A persisted or URL sort can name a column with no `sortBy` (or a hidden one): the\n // sort chain drops it, and so does the control rather than claiming it.\n const primary = sorts[0] && columns.some((c) => c.key === sorts[0].key) ? sorts[0] : null;\n const offerDefault = sortCycle === \"tri\" || primary === null;\n const desc = primary?.dir === \"desc\";\n return (\n <div\n data-table-mobile-sort=\"\"\n className={cn(\n // At Large the direction button shows its words (IconButton's label, §4); the\n // row wraps so the select keeps a usable width on a 240 px card.\n \"flex flex-wrap items-center gap-2 border-b border-subtle\",\n compact ? \"px-3 py-1.5\" : \"px-4 py-2\",\n )}\n >\n <label htmlFor={selectId} className=\"shrink-0 text-xs text-muted\">\n {sortLabels.sortBy}\n </label>\n <Select\n id={selectId}\n size={compact ? \"sm\" : \"md\"}\n className=\"min-w-0 flex-1 large:basis-40\"\n value={primary?.key ?? \"\"}\n onChange={(e) => onPick(e.target.value || null)}\n >\n {offerDefault && <option value=\"\">{sortLabels.sortDefault}</option>}\n {columns.map((col) => (\n <option key={col.key} value={col.key}>\n {columnLabel(col, labels)}\n </option>\n ))}\n </Select>\n <IconButton\n size={compact ? \"xs\" : \"md\"}\n variant=\"secondary\"\n label={desc ? sortLabels.sortDescending : sortLabels.sortAscending}\n disabled={primary === null}\n onClick={onFlip}\n >\n {desc ? <ArrowDownWideNarrow /> : <ArrowUpNarrowWide />}\n </IconButton>\n </div>\n );\n}\n\n// ---------- Phone card ----------\n\n/** One phone card: what DataTable's card list draws for a row. */\nexport interface MobileRowProps<T> {\n row: T;\n isExpanded: DataTableProps<T>[\"isExpanded\"];\n /** What the row shows when opened — DataTable's `openedRowContent`. */\n openedRowContent: (row: T) => ReactNode;\n /** Whether an opened row has anything to show at all. */\n opensRow: boolean;\n rowClassName: DataTableProps<T>[\"rowClassName\"];\n rowAttributes: DataTableProps<T>[\"rowAttributes\"];\n onRowClick: DataTableProps<T>[\"onRowClick\"];\n rowHref: DataTableProps<T>[\"rowHref\"];\n /** `mobileSwipeActions`, or the swipes derived from `rowActions`. */\n swipeActionsFor: ((row: T) => MobileSwipeActions | null) | undefined;\n /** The card list's reading direction, for which physical drag a swipe side means. */\n mobileDir: Direction;\n /** No column the card renders owns a link, so the card itself may be one. */\n mobileCardLinkable: boolean;\n mobileCard: DataTableProps<T>[\"mobileCard\"];\n mobilePrimaryCol: DataTableColumn<T> | null;\n mobileSecondaryColumns: DataTableColumn<T>[];\n mobileExpandAsDialog: boolean;\n compact: boolean;\n /** The text size is Large or Extra large (§4). */\n large: boolean;\n /** Phone selection (0.40, keksdose live #421); null without DataTable's `selection`. */\n select: MobileRowSelect | null;\n}\n\n/** A phone card's part in DataTable's `selection` (0.40, keksdose live #421). */\nexport interface MobileRowSelect {\n /** The card list is in selection mode: the card shows its box and a tap toggles it. */\n active: boolean;\n /** `selection.isSelectable` for this row. */\n selectable: boolean;\n selected: boolean;\n onToggle: (checked: boolean) => void;\n /** A long press outside the mode: enter it with this row selected. */\n onLongPress: () => void;\n}\n\n/** One mobile card. Shared by the flat list and the grouped list, so both draw\n * identical row markup. */\nexport function MobileRow<T>({\n row,\n isExpanded,\n openedRowContent,\n opensRow,\n rowClassName,\n rowAttributes,\n onRowClick,\n rowHref,\n swipeActionsFor,\n mobileDir,\n mobileCardLinkable,\n mobileCard,\n mobilePrimaryCol,\n mobileSecondaryColumns,\n mobileExpandAsDialog,\n compact,\n large,\n select,\n}: MobileRowProps<T>) {\n const expanded = isExpanded?.(row) ?? false;\n // Selection mode (#421): the whole card is the row's checkbox — a tap toggles it\n // instead of opening the row, and the swipe and the long press stand down, so no\n // gesture means two things at once (keksdose's register list, its feedback #153).\n const selecting = !!select?.active;\n // Not on an open card, as in kk's register: its editor and a selection are two modes\n // fighting over the same row.\n const pressable = !!select && select.selectable && !selecting && !expanded;\n const longPress = useLongPress(() => select?.onLongPress(), { enabled: pressable });\n const expansion = expanded ? openedRowContent(row) : null;\n const tint = rowClassName?.(row);\n // Use div+role=\"button\" rather than a real <button> so cells that\n // contain their own interactive controls (status toggles, action\n // icons) don't end up as illegal nested buttons.\n const interactive = !!onRowClick;\n // Say that the card OPENS something (Keksdose feedback #163). A phone card had\n // only a cursor and a tap-tint to advertise its editor — on the invoice review\n // screen, whose entire purpose is correcting mis-read lines, that read as \"these\n // values are not editable\". A chevron is the affordance every list on a phone\n // uses for exactly this, and it points the way the row actually opens: right into\n // a sheet, or down into an inline panel that flips it when expanded.\n const opensDetail = interactive && opensRow;\n const swipeActions = swipeActionsFor?.(row);\n const swipe = swipeActions ? physicalSwipe(swipeActions, mobileDir) : null;\n // An OPEN card swipes too (0.40, keksdose live #416, Marcel: \"faster than scrolling\n // down to the buttons\"). Only the card's header — the summary line — is inside\n // SwipeableRow; the opened body below is a sibling, so its fields, sliders and\n // scrolling never meet the gesture. Until 0.40 an open card silently lost it.\n // In selection mode a sideways drag would be a second meaning for a selecting touch.\n const swipeEnabled = !!swipe && !selecting;\n const href = mobileCardLinkable ? rowHref?.(row) : undefined;\n const cardClass = cn(\n \"w-full text-start flex items-center\",\n // §4: more room per row at Large — a taller row is a bigger tap target too.\n compact ? \"px-3 py-2 gap-2 text-sm large:py-3\" : \"px-4 py-3 gap-3 large:py-4\",\n // Live #320's other half: the card acknowledges the touch before the sheet\n // arrives. On a cold route the data can take a beat, and an unacknowledged tap\n // reads as \"did that register?\" — which is most of what \"abrupt\" means here.\n // `origin-center` + a 0.5% squeeze is deliberately almost subliminal: this fires\n // on every row of a long list, so anything larger becomes the list's personality\n // rather than feedback. `transition-transform` alone, so the existing background\n // flip stays instant.\n interactive &&\n \"cursor-pointer transition-transform duration-100 active:scale-[0.995] active:bg-active motion-reduce:transition-none motion-reduce:active:scale-100\",\n // The keyboard's frame drawn INSIDE the card, as the desktop row's is (keksdose live\n // #394): the browser's own ring and tap highlight sit on the card's outer edge, and\n // three ancestors clip it — SwipeableRow's `overflow-hidden`, the card list, and\n // AppShell main's `overflow-x-clip` — so the frame went missing on one side. A press\n // keeps the fill and the squeeze above; an outline on every tap of a long list would\n // be louder than the acknowledgement it replaces.\n (interactive || selecting) && cn(\"[-webkit-tap-highlight-color:transparent] focus-visible:ring-inset\", FOCUS_RING),\n selecting && \"cursor-pointer\",\n select?.selected && selecting && \"bg-brand-soft\",\n // A 500 ms hold would otherwise start a text selection, and iOS's link callout —\n // which only yields to this CSS. The trade kk made on its register: the card's text\n // is not selectable on a phone where the table offers selection.\n select && \"select-none [-webkit-touch-callout:none]\",\n );\n // The card's content is written once and worn by either tag below. Note that\n // the primary cell is rendered RAW here, never through `linkColumn` — the card\n // itself is the anchor, and routing it through `linkColumn` too would nest an\n // <a> inside an <a>. Desktop and mobile are mutually exclusive (`isMdUp`), so\n // exactly one anchor exists per row.\n const cardInner = (\n <>\n {/* The card's own content keeps the column it always had; the chevron sits\n beside it rather than inside, so a caller's `mobileCard` is untouched. */}\n <div\n className={cn(\n \"flex min-w-0 flex-1 flex-col\",\n compact ? \"gap-1 large:gap-2\" : \"gap-2 large:gap-3\",\n // §4 \"nothing truncates\": at Large a cell's own `truncate` wraps instead —\n // the reader who chose bigger type is the one an ellipsis fails. A caller's\n // `mobileCard` is its own layout and keeps what it chose.\n !mobileCard && CARD_NO_TRUNCATE,\n )}\n >\n {mobileCard ? (\n mobileCard(row)\n ) : (\n <>\n {/* Only the `data-*` half of `cellProps` — see its note. */}\n {mobilePrimaryCol && (\n <div {...dataAttrsOf(mobilePrimaryCol.cellProps?.(row))} className=\"min-w-0 break-words font-medium\">\n {mobilePrimaryCol.cell(row)}\n </div>\n )}\n {mobileSecondaryColumns.length > 0 && large ? (\n // §4: each label ABOVE its value at Large. Side by side, a label column\n // sized to the widest label leaves a value a sliver of a 240 px card.\n <StackedFields columns={mobileSecondaryColumns} row={row} compact={compact} />\n ) : mobileSecondaryColumns.length > 0 && (\n <dl\n className={cn(\n \"grid grid-cols-[auto_1fr] gap-x-3\",\n compact ? \"gap-y-0.5 text-xs\" : \"gap-y-1 text-sm\",\n )}\n >\n {mobileSecondaryColumns.map((col) => (\n <Fragment key={col.key}>\n <dt className=\"text-xs uppercase tracking-wide text-muted self-center\">\n {col.header}\n </dt>\n <dd\n {...dataAttrsOf(col.cellProps?.(row))}\n className=\"min-w-0 text-secondary self-center\"\n >\n {col.cell(row)}\n </dd>\n </Fragment>\n ))}\n </dl>\n )}\n </>\n )}\n </div>\n {opensDetail && !selecting &&\n // `aria-hidden`: the row already announces itself as a button with\n // `aria-expanded`, so the icon would only add a second, wordless stop.\n (mobileExpandAsDialog ? (\n // Points INTO the sheet, which is the reading-forward direction.\n <ChevronRight\n aria-hidden\n className=\"size-4 shrink-0 text-placeholder rtl:-scale-x-100\"\n />\n ) : (\n <ChevronDown\n aria-hidden\n className={cn(\n \"size-4 shrink-0 text-placeholder transition-transform\",\n expanded && \"rotate-180\",\n )}\n />\n ))}\n </>\n );\n // The long press rides the card itself; the click it leaves behind is swallowed on\n // the `<li>` below, in the capture phase, before the card (or RowLink) sees it.\n // Attached whenever the table selects, the hook's `enabled` deciding whether a press\n // arms: its pointerdown also clears a stale swallow latch, which must happen in the\n // mode too, or a long press whose click never came would eat the first toggle.\n const press = select ? longPress.handlers : undefined;\n const body = selecting ? (\n // In the mode a card is a toggle — kk's register row: `role=\"button\"` with\n // `aria-pressed`, the box beside it only a picture of that state (the card is the\n // one control, so the two can never fight or fire twice).\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=\"button\",\n tabIndex 0 and Enter/Space are all here; the rule cannot follow the conditional role. */\n <div\n role={select!.selectable ? \"button\" : undefined}\n tabIndex={select!.selectable ? 0 : undefined}\n aria-pressed={select!.selectable ? select!.selected : undefined}\n aria-disabled={select!.selectable ? undefined : true}\n data-card-selecting=\"\"\n {...press}\n onClick={(e) => {\n if (!select!.selectable || fromOutside(e.target, e.currentTarget)) return;\n select!.onToggle(!select!.selected);\n }}\n onKeyDown={(e) => {\n if (!select!.selectable || e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n select!.onToggle(!select!.selected);\n }\n }}\n className={cardClass}\n >\n <Checkbox\n readOnly\n tabIndex={-1}\n aria-hidden\n checked={select!.selected}\n disabled={!select!.selectable}\n className=\"pointer-events-none shrink-0 self-center\"\n inputClassName=\"pointer-events-none\"\n />\n {cardInner}\n </div>\n ) : interactive && href ? (\n // A card with a URL of its own is a LINK, not a div wearing role=\"button\"\n // (feedback #451): the phone's long-press \"open in new tab\" and a screen\n // reader's link semantics both come free, and the fake role goes away.\n <RowLink\n href={href}\n {...press}\n onActivate={() => onRowClick!(row)}\n aria-expanded={opensRow ? expanded : undefined}\n className={cardClass}\n // Enter already activates a link natively, and that routes through\n // RowLink's own onClick — handling it here too would open the row twice.\n // Space is the only key left, and only so it opens the row instead of\n // scrolling the list.\n onKeyDown={(e) => {\n if (e.key !== \" \") return;\n e.preventDefault();\n onRowClick!(row);\n }}\n >\n {cardInner}\n </RowLink>\n ) : (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers are\n only attached when `interactive`, and then so are role=\"button\", tabIndex 0 and the\n Enter/Space handler below; the rule cannot follow the conditional role. */\n <div\n {...press}\n role={interactive ? \"button\" : undefined}\n tabIndex={interactive ? 0 : undefined}\n onClick={\n interactive\n ? (e) => {\n if (fromOutside(e.target, e.currentTarget)) return;\n onRowClick!(row);\n }\n : undefined\n }\n onKeyDown={\n interactive\n ? (e) => {\n // A key press inside a nested control is that control's.\n if (e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onRowClick!(row);\n }\n }\n : undefined\n }\n aria-expanded={opensRow ? expanded : undefined}\n className={cardClass}\n >\n {cardInner}\n </div>\n );\n return (\n <li\n className={cn(tint)}\n {...cleanAttrs(rowAttributes?.(row))}\n data-selected={selecting && select!.selected ? \"\" : undefined}\n onClickCapture={\n select\n ? (e) => {\n if (!longPress.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }\n : undefined\n }\n >\n {swipeEnabled ? (\n <SwipeableRow enabled left={swipe!.left} right={swipe!.right}>\n {body}\n </SwipeableRow>\n ) : (\n body\n )}\n {expansion && !mobileExpandAsDialog && (\n <div\n className={cn(\n \"bg-surface-2 border-t border-subtle\",\n compact ? \"px-3 py-2\" : \"px-4 py-3\",\n )}\n >\n {expansion}\n </div>\n )}\n </li>\n );\n}\n\n// ---------- Phone selection bar (0.40, keksdose live #421) ----------\n\n/**\n * The card list's way into and out of selection mode for a keyboard and a screen\n * reader — the long press is a touch gesture, and a gesture alone is no path at all.\n *\n * Outside the mode it is one button, \"Select rows\", visually hidden until it takes\n * focus (SwipeableRow's keyboard buttons, the same rule): the card list keeps the look\n * kk's register has, where the long press is the way in, and Tab or a screen reader's\n * cursor still finds it. In the mode it is a visible row: \"Select all rows\" — the\n * desktop header's box — and Cancel, which clears the selection and leaves, as the\n * bulk bar's Clear does. The owner's bulk bar is the owner's, as on desktop: it\n * appears because the selection is no longer empty.\n */\nexport function MobileSelectBar({\n active,\n allSelected,\n someSelected,\n compact,\n labels,\n onEnter,\n onToggleAll,\n onCancel,\n}: {\n active: boolean;\n allSelected: boolean;\n someSelected: boolean;\n compact: boolean;\n labels: DataTableLabels;\n onEnter: () => void;\n onToggleAll: (checked: boolean) => void;\n onCancel: () => void;\n}) {\n // The control that was pressed unmounts with the mode it switched, so focus is\n // handed across: into the bar's box on the way in, back to the button on the way out.\n const handOff = useRef(false);\n const allBox = useRef<HTMLInputElement>(null);\n const enterButton = useRef<HTMLButtonElement>(null);\n useEffect(() => {\n if (!handOff.current) return;\n handOff.current = false;\n (active ? allBox.current : enterButton.current)?.focus();\n }, [active]);\n if (!active)\n return (\n <button\n ref={enterButton}\n type=\"button\"\n data-table-mobile-select=\"\"\n onClick={() => {\n handOff.current = true;\n onEnter();\n }}\n className={cn(\n \"sr-only rounded-md text-sm font-medium text-secondary\",\n // Padding only once shown: unconditional, it beats sr-only's `padding: 0`\n // (SwipeableRow's 0.32.1 note).\n \"focus:not-sr-only focus:m-2 focus:inline-flex focus:px-2 focus:py-1\",\n FOCUS_RING,\n )}\n >\n {labels.selectRows}\n </button>\n );\n return (\n <div\n data-table-mobile-select=\"\"\n className={cn(\n \"flex flex-wrap items-center justify-between gap-2 border-b border-subtle\",\n compact ? \"px-3 py-1.5\" : \"px-4 py-2\",\n )}\n >\n <Checkbox\n ref={allBox}\n label={labels.selectAllRows}\n checked={allSelected}\n indeterminate={someSelected && !allSelected}\n onChange={(e) => onToggleAll(e.target.checked)}\n />\n <button\n type=\"button\"\n onClick={() => {\n handOff.current = true;\n onCancel();\n }}\n className={cn(\"rounded-md px-2 py-1 text-sm text-muted hover:text-secondary\", FOCUS_RING)}\n >\n {labels.cancelSelection}\n </button>\n </div>\n );\n}\n\n// ---------- Mobile filter sheet ----------\n\n/**\n * Filter access for the mobile card list (feedback #299). The desktop table\n * houses per-column filters in its header; the card list has no header, so the\n * same filters were unreachable on phones. This renders a compact \"Filters\" bar\n * that opens a bottom sheet listing each filterable column as a collapsible\n * section, reusing the very same {@link FilterPopover} and filter state the\n * desktop table drives — so it filters identically and stays URL-synced.\n *\n * The sheet is a {@link DialogFrame} (round 0.34 BUG-7b = DUP-6). It was the last\n * hand-rolled modal in the kit: `role=\"dialog\" aria-modal=\"true\"` on the backdrop, with\n * copies of the scroll lock, the backdrop close and the Back entry — and no name, no\n * focus moved in, no trap and no Escape, so a reader was told the page was hidden while\n * focus stayed on it. On a phone `Modal` is a bottom sheet already; it now brings all\n * of that, and the exit motion, from the one place every other dialog gets it.\n */\nexport function MobileFilters<T>({\n columns,\n filters,\n selectOptionsByKey,\n isServer,\n hasFilterCallback,\n onSetFilter,\n onClearFilter,\n onClearAll,\n labels,\n locale,\n}: {\n columns: DataTableColumn<T>[];\n filters: FilterState;\n selectOptionsByKey: Record<string, { value: string; label: string }[]>;\n isServer: boolean;\n hasFilterCallback: boolean;\n onSetFilter: (key: string, value: FilterValue) => void;\n onClearFilter: (key: string) => void;\n onClearAll: () => void;\n labels: DataTableLabels;\n locale?: string;\n}) {\n const [open, setOpen] = useState(false);\n // The sheet is portalled to <body>, out from under whatever `dir` the table sits\n // in, so it carries the trigger's direction with it — read at the moment of opening,\n // from the element the user actually pressed.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const [expanded, setExpanded] = useState<string | null>(null);\n\n const filterable = useMemo(\n () =>\n columns\n .map((col) => ({ col, filter: !isServer || hasFilterCallback ? resolveFilter(col) : null }))\n .filter((x): x is { col: DataTableColumn<T>; filter: ColumnFilter<T> } => !!x.filter),\n [columns, isServer, hasFilterCallback],\n );\n\n if (filterable.length === 0) return null;\n\n const activeCount = filterable.filter(({ col }) => {\n const s = filters[col.key];\n return s && isFilterActive(s);\n }).length;\n\n return (\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-4 py-2\">\n <button\n type=\"button\"\n onClick={(e) => {\n setDir(dirOf(e.currentTarget));\n setOpen(true);\n }}\n className=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-secondary hover:bg-hover\"\n >\n <Filter className=\"size-4\" />\n <span>{labels.filters}</span>\n {activeCount > 0 && (\n <span className=\"inline-flex min-w-4 items-center justify-center rounded-full bg-info-soft px-1 text-caption font-semibold text-info\">\n {activeCount}\n </span>\n )}\n </button>\n {activeCount > 0 && (\n <button\n type=\"button\"\n onClick={onClearAll}\n className=\"text-xs text-muted hover:text-secondary\"\n >\n {labels.clearAll}\n </button>\n )}\n {/* Escape, the backdrop, Back (Keksdose feedback #172: the filters are already\n in the URL, so closing never loses anything), the scroll lock, focus in and\n back to this bar's button — all `Modal`'s. `fullBleed` lets the sheet meet the\n screen's edges on a phone, as it always did. */}\n <DialogFrame\n open={open}\n onClose={() => setOpen(false)}\n title={labels.filters}\n closeButton\n closeLabel={labels.close}\n headerDivider\n fullBleed\n // Portalled to <body>, so the direction read from the button goes with it.\n dir={dir}\n className=\"max-h-[85vh] rounded-b-none md:rounded-b-lg\"\n // The sections run edge to edge with a rule between them, as before.\n bodyClassName=\"space-y-0 divide-y divide-subtle px-0 pt-0 pb-0\"\n actions={(close) => (\n <>\n <button\n type=\"button\"\n onClick={onClearAll}\n disabled={activeCount === 0}\n // `me-auto` keeps Clear all at the start and Done at the end.\n className=\"me-auto text-sm text-muted hover:text-secondary disabled:opacity-40\"\n >\n {labels.clearAll}\n </button>\n <button\n type=\"button\"\n onClick={close}\n className=\"rounded-md bg-inverse px-4 py-2 text-sm font-medium text-inverse\"\n >\n {labels.done}\n </button>\n </>\n )}\n >\n {filterable.map(({ col, filter }) => {\n const state = filters[col.key];\n const active = !!state && isFilterActive(state);\n const isItemOpen = expanded === col.key;\n return (\n <div key={col.key}>\n <button\n type=\"button\"\n onClick={() => setExpanded(isItemOpen ? null : col.key)}\n aria-expanded={isItemOpen}\n className=\"flex w-full items-center justify-between gap-2 px-4 py-3 text-start\"\n >\n <span className=\"flex items-center gap-2 text-sm font-medium text-secondary\">\n {col.header}\n {active && <span className=\"size-1.5 rounded-full bg-brand\" />}\n </span>\n <ChevronDown\n className={cn(\n \"size-4 shrink-0 text-placeholder transition-transform\",\n isItemOpen && \"rotate-180\",\n )}\n />\n </button>\n {isItemOpen && (\n <div className=\"px-4 pb-3\">\n <FilterPopover\n column={col}\n state={state ?? defaultFilterState(filter)}\n onChange={(next) => onSetFilter(col.key, next)}\n onClear={() => onClearFilter(col.key)}\n selectOptions={selectOptionsByKey[col.key] ?? []}\n labels={labels}\n locale={locale}\n />\n </div>\n )}\n </div>\n );\n })}\n </DialogFrame>\n </div>\n );\n}\n"],"mappings":"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","names":["Fragment"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/data-table-mobile.tsx"],"sourcesContent":["import { Fragment, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { ArrowDownWideNarrow, ArrowUpNarrowWide, ChevronDown, ChevronRight, Filter } from \"lucide-react\";\nimport { FOCUS_RING } from \"./focus-ring\";\nimport { IconButton } from \"./icon-button\";\nimport { Select } from \"./select\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { defaultFilterState, isFilterActive, resolveFilter } from \"./data-table-filters\";\nimport type { ColumnFilter, FilterValue } from \"./data-table-filters\";\nimport type { SortCycle, SortState } from \"./data-table-sort\";\nimport { FilterPopover } from \"./data-table-filter-popover\";\nimport { SwipeableRow, type SwipeAction } from \"./swipeable-row\";\nimport { DialogFrame } from \"./dialog-frame\";\nimport { Checkbox } from \"./checkbox\";\nimport { useLongPress } from \"../hooks/use-long-press\";\nimport type { DataTableLabels } from \"./data-table-labels\";\nimport { RowLink, cleanAttrs, columnLabel, fromOutside, fromOwnControl } from \"./data-table-shared\";\nimport type {\n DataTableCellProps,\n DataTableColumn,\n DataTableProps,\n DataTableSortLabels,\n FilterState,\n MobileSwipeActions,\n} from \"./data-table\";\n\n// DataTable's phone layout: the card a row becomes, the fields inside it, and the sort\n// and filter controls above the card list. Moved out of data-table.tsx whole (refactor\n// audit 2026-10, MOD-3 step 4); the list itself, its pager and its row dialog stay in\n// DataTable, which owns the state they share with the wide-screen table.\n\n/** Only the `data-*` attributes of a cell's props — what a phone card field takes. */\nfunction dataAttrsOf(props: DataTableCellProps | undefined): Record<string, unknown> | undefined {\n if (!props) return undefined;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(props)) if (k.startsWith(\"data-\") && k !== \"data-col\") out[k] = v;\n return out;\n}\n\n/** The logical sides onto the physical drag directions {@link SwipeableRow} takes. */\nfunction physicalSwipe(\n actions: MobileSwipeActions,\n dir: Direction,\n): { left?: SwipeAction[]; right?: SwipeAction[] } {\n const rtl = dir === \"rtl\";\n return {\n left: (rtl ? actions.end : actions.start) ?? actions.left,\n right: (rtl ? actions.start : actions.end) ?? actions.right,\n };\n}\n\n// ---------- Phone card at Large (0.32) ----------\n\n/**\n * §4 \"nothing truncates\" in a phone card at Large and Extra large: a cell's own\n * `truncate` (a kit cell, a chip, the app's span) wraps instead — and breaks inside a\n * word too long for the card rather than running out of it. A descendant rule, so it\n * reaches the cells without the table knowing what they render; `large:`, so Normal\n * keeps every ellipsis it had.\n */\nconst CARD_NO_TRUNCATE =\n \"large:[&_.truncate]:whitespace-normal large:[&_.truncate]:[overflow-wrap:anywhere]\";\n\n/**\n * Columns as label-above-value pairs: the phone card's fields at Large (§4), and the\n * opened row's `mobileDetailsInRow` at every size. Each pair is a `<div>` in the\n * `<dl>` — the grouping HTML allows — so a pair keeps together and the gap falls\n * BETWEEN facts rather than between a label and its own value. `relative` holds a\n * header's `sr-only` text (an actions column's), as every kit container must.\n */\nexport function StackedFields<T>({ columns, row, compact }: { columns: DataTableColumn<T>[]; row: T; compact: boolean }) {\n return (\n <dl\n data-card-fields=\"stacked\"\n className={cn(\"relative flex min-w-0 flex-col\", compact ? \"gap-1.5 text-xs\" : \"gap-2.5 text-sm\", CARD_NO_TRUNCATE)}\n >\n {columns.map((col) => (\n <div key={col.key} className=\"min-w-0\">\n <dt className=\"text-xs uppercase tracking-wide text-muted\">{col.header}</dt>\n <dd\n {...dataAttrsOf(col.cellProps?.(row))}\n className=\"mt-0.5 min-w-0 break-words text-secondary\"\n >\n {col.cell(row)}\n </dd>\n </div>\n ))}\n </dl>\n );\n}\n\n// ---------- Phone sort control (keksdose K10) ----------\n\n/**\n * The card list's sort control: a \"Sort by\" select and a direction button.\n *\n * keksdose K10 (inputs audit 2026-10-02 §5; its aggregated report tables, #258): a\n * table is sorted by its header row and the phone card list has none, so the two\n * decisions the header carries — which column, which direction — were unreachable\n * below the breakpoint. keksdose hand-built this row over its DataTable to answer\n * \"who did I pay the most\" on the device it was asked on; this is that row, driven by\n * the SAME sort state as the headers (controlled `sorts` included), so turning the\n * phone sideways keeps the order and a sort chosen here is the one the URL and the\n * storage key hold.\n *\n * The rules are the header's: a newly picked column starts in its `firstSort`, the\n * direction button flips the primary sort (secondary ones stay), and \"Default order\"\n * — the third click of a `\"tri\"` cycle — is offered only under `\"tri\"`, since a\n * `\"toggle\"` table promises it is always ranked by something. The select's label is\n * a real `<label>` beside it, and the button sits outside that label: a click inside\n * a label is forwarded to the control it names, so a nested button would flip AND\n * open the select.\n */\nexport function MobileSort<T>({\n columns,\n sorts,\n sortCycle,\n compact,\n labels,\n onPick,\n onFlip,\n}: {\n columns: DataTableColumn<T>[];\n sorts: SortState[];\n sortCycle: SortCycle;\n compact: boolean;\n labels: DataTableLabels;\n onPick: (key: string | null) => void;\n onFlip: () => void;\n}) {\n const selectId = useId();\n const sortLabels: DataTableSortLabels = labels;\n if (columns.length === 0) return null;\n // A persisted or URL sort can name a column with no `sortBy` (or a hidden one): the\n // sort chain drops it, and so does the control rather than claiming it.\n const primary = sorts[0] && columns.some((c) => c.key === sorts[0].key) ? sorts[0] : null;\n const offerDefault = sortCycle === \"tri\" || primary === null;\n const desc = primary?.dir === \"desc\";\n return (\n <div\n data-table-mobile-sort=\"\"\n className={cn(\n // At Large the direction button shows its words (IconButton's label, §4); the\n // row wraps so the select keeps a usable width on a 240 px card.\n \"flex flex-wrap items-center gap-2 border-b border-subtle\",\n compact ? \"px-3 py-1.5\" : \"px-4 py-2\",\n )}\n >\n <label htmlFor={selectId} className=\"shrink-0 text-xs text-muted\">\n {sortLabels.sortBy}\n </label>\n <Select\n id={selectId}\n size={compact ? \"sm\" : \"md\"}\n className=\"min-w-0 flex-1 large:basis-40\"\n value={primary?.key ?? \"\"}\n onChange={(e) => onPick(e.target.value || null)}\n >\n {offerDefault && <option value=\"\">{sortLabels.sortDefault}</option>}\n {columns.map((col) => (\n <option key={col.key} value={col.key}>\n {columnLabel(col, labels)}\n </option>\n ))}\n </Select>\n <IconButton\n size={compact ? \"xs\" : \"md\"}\n variant=\"secondary\"\n label={desc ? sortLabels.sortDescending : sortLabels.sortAscending}\n disabled={primary === null}\n onClick={onFlip}\n >\n {desc ? <ArrowDownWideNarrow /> : <ArrowUpNarrowWide />}\n </IconButton>\n </div>\n );\n}\n\n// ---------- Phone card ----------\n\n/** One phone card: what DataTable's card list draws for a row. */\nexport interface MobileRowProps<T> {\n row: T;\n isExpanded: DataTableProps<T>[\"isExpanded\"];\n /** What the row shows when opened — DataTable's `openedRowContent`. */\n openedRowContent: (row: T) => ReactNode;\n /** Whether an opened row has anything to show at all. */\n opensRow: boolean;\n rowClassName: DataTableProps<T>[\"rowClassName\"];\n rowAttributes: DataTableProps<T>[\"rowAttributes\"];\n onRowClick: DataTableProps<T>[\"onRowClick\"];\n rowHref: DataTableProps<T>[\"rowHref\"];\n /** `mobileSwipeActions`, or the swipes derived from `rowActions`. */\n swipeActionsFor: ((row: T) => MobileSwipeActions | null) | undefined;\n /** The card list's reading direction, for which physical drag a swipe side means. */\n mobileDir: Direction;\n /** No column the card renders owns a link, so the card itself may be one. */\n mobileCardLinkable: boolean;\n mobileCard: DataTableProps<T>[\"mobileCard\"];\n mobilePrimaryCol: DataTableColumn<T> | null;\n mobileSecondaryColumns: DataTableColumn<T>[];\n mobileExpandAsDialog: boolean;\n compact: boolean;\n /** The text size is Large or Extra large (§4). */\n large: boolean;\n /** Phone selection (0.40, keksdose live #421); null without DataTable's `selection`. */\n select: MobileRowSelect | null;\n}\n\n/** A phone card's part in DataTable's `selection` (0.40, keksdose live #421). */\nexport interface MobileRowSelect {\n /** The card list is in selection mode: the card shows its box and a tap toggles it. */\n active: boolean;\n /** `selection.isSelectable` for this row. */\n selectable: boolean;\n selected: boolean;\n onToggle: (checked: boolean) => void;\n /** A long press outside the mode: enter it with this row selected. */\n onLongPress: () => void;\n}\n\n/** One mobile card. Shared by the flat list and the grouped list, so both draw\n * identical row markup. */\nexport function MobileRow<T>({\n row,\n isExpanded,\n openedRowContent,\n opensRow,\n rowClassName,\n rowAttributes,\n onRowClick,\n rowHref,\n swipeActionsFor,\n mobileDir,\n mobileCardLinkable,\n mobileCard,\n mobilePrimaryCol,\n mobileSecondaryColumns,\n mobileExpandAsDialog,\n compact,\n large,\n select,\n}: MobileRowProps<T>) {\n const expanded = isExpanded?.(row) ?? false;\n // Selection mode (#421): the whole card is the row's checkbox — a tap toggles it\n // instead of opening the row, and the swipe and the long press stand down, so no\n // gesture means two things at once (keksdose's register list, its feedback #153).\n const selecting = !!select?.active;\n // Not on an open card, as in kk's register: its editor and a selection are two modes\n // fighting over the same row.\n const pressable = !!select && select.selectable && !selecting && !expanded;\n const longPress = useLongPress(() => select?.onLongPress(), { enabled: pressable });\n const expansion = expanded ? openedRowContent(row) : null;\n const tint = rowClassName?.(row);\n // Use div+role=\"button\" rather than a real <button> so cells that\n // contain their own interactive controls (status toggles, action\n // icons) don't end up as illegal nested buttons.\n const interactive = !!onRowClick;\n // Say that the card OPENS something (Keksdose feedback #163). A phone card had\n // only a cursor and a tap-tint to advertise its editor — on the invoice review\n // screen, whose entire purpose is correcting mis-read lines, that read as \"these\n // values are not editable\". A chevron is the affordance every list on a phone\n // uses for exactly this, and it points the way the row actually opens: right into\n // a sheet, or down into an inline panel that flips it when expanded.\n const opensDetail = interactive && opensRow;\n const swipeActions = swipeActionsFor?.(row);\n const swipe = swipeActions ? physicalSwipe(swipeActions, mobileDir) : null;\n // An OPEN card swipes too (0.40, keksdose live #416, Marcel: \"faster than scrolling\n // down to the buttons\"). Only the card's header — the summary line — is inside\n // SwipeableRow; the opened body below is a sibling, so its fields, sliders and\n // scrolling never meet the gesture. Until 0.40 an open card silently lost it.\n // In selection mode a sideways drag would be a second meaning for a selecting touch.\n const swipeEnabled = !!swipe && !selecting;\n const href = mobileCardLinkable ? rowHref?.(row) : undefined;\n const cardClass = cn(\n \"w-full text-start flex items-center\",\n // §4: more room per row at Large — a taller row is a bigger tap target too.\n compact ? \"px-3 py-2 gap-2 text-sm large:py-3\" : \"px-4 py-3 gap-3 large:py-4\",\n // Live #320's other half: the card acknowledges the touch before the sheet\n // arrives. On a cold route the data can take a beat, and an unacknowledged tap\n // reads as \"did that register?\" — which is most of what \"abrupt\" means here.\n // `origin-center` + a 0.5% squeeze is deliberately almost subliminal: this fires\n // on every row of a long list, so anything larger becomes the list's personality\n // rather than feedback. `transition-transform` alone, so the existing background\n // flip stays instant.\n interactive &&\n \"cursor-pointer transition-transform duration-100 active:scale-[0.995] active:bg-active motion-reduce:transition-none motion-reduce:active:scale-100\",\n // The keyboard's frame drawn INSIDE the card, as the desktop row's is (keksdose live\n // #394): the browser's own ring and tap highlight sit on the card's outer edge, and\n // three ancestors clip it — SwipeableRow's `overflow-hidden`, the card list, and\n // AppShell main's `overflow-x-clip` — so the frame went missing on one side. A press\n // keeps the fill and the squeeze above; an outline on every tap of a long list would\n // be louder than the acknowledgement it replaces.\n (interactive || selecting) && cn(\"[-webkit-tap-highlight-color:transparent] focus-visible:ring-inset\", FOCUS_RING),\n selecting && \"cursor-pointer\",\n select?.selected && selecting && \"bg-brand-soft\",\n // A 500 ms hold would otherwise start a text selection, and iOS's link callout —\n // which only yields to this CSS. The trade kk made on its register: the card's text\n // is not selectable on a phone where the table offers selection.\n select && \"select-none [-webkit-touch-callout:none]\",\n );\n // The card's content is written once and worn by either tag below. Note that\n // the primary cell is rendered RAW here, never through `linkColumn` — the card\n // itself is the anchor, and routing it through `linkColumn` too would nest an\n // <a> inside an <a>. Desktop and mobile are mutually exclusive (`isMdUp`), so\n // exactly one anchor exists per row.\n const cardInner = (\n <>\n {/* The card's own content keeps the column it always had; the chevron sits\n beside it rather than inside, so a caller's `mobileCard` is untouched. */}\n <div\n className={cn(\n \"flex min-w-0 flex-1 flex-col\",\n compact ? \"gap-1 large:gap-2\" : \"gap-2 large:gap-3\",\n // §4 \"nothing truncates\": at Large a cell's own `truncate` wraps instead —\n // the reader who chose bigger type is the one an ellipsis fails. A caller's\n // `mobileCard` is its own layout and keeps what it chose.\n !mobileCard && CARD_NO_TRUNCATE,\n )}\n >\n {mobileCard ? (\n mobileCard(row)\n ) : (\n <>\n {/* Only the `data-*` half of `cellProps` — see its note. */}\n {mobilePrimaryCol && (\n <div {...dataAttrsOf(mobilePrimaryCol.cellProps?.(row))} className=\"min-w-0 break-words font-medium\">\n {mobilePrimaryCol.cell(row)}\n </div>\n )}\n {mobileSecondaryColumns.length > 0 && large ? (\n // §4: each label ABOVE its value at Large. Side by side, a label column\n // sized to the widest label leaves a value a sliver of a 240 px card.\n <StackedFields columns={mobileSecondaryColumns} row={row} compact={compact} />\n ) : mobileSecondaryColumns.length > 0 && (\n <dl\n className={cn(\n \"grid grid-cols-[auto_1fr] gap-x-3\",\n compact ? \"gap-y-0.5 text-xs\" : \"gap-y-1 text-sm\",\n )}\n >\n {mobileSecondaryColumns.map((col) => (\n <Fragment key={col.key}>\n <dt className=\"text-xs uppercase tracking-wide text-muted self-center\">\n {col.header}\n </dt>\n <dd\n {...dataAttrsOf(col.cellProps?.(row))}\n className=\"min-w-0 text-secondary self-center\"\n >\n {col.cell(row)}\n </dd>\n </Fragment>\n ))}\n </dl>\n )}\n </>\n )}\n </div>\n {opensDetail && !selecting &&\n // `aria-hidden`: the row already announces itself as a button with\n // `aria-expanded`, so the icon would only add a second, wordless stop.\n (mobileExpandAsDialog ? (\n // Points INTO the sheet, which is the reading-forward direction.\n <ChevronRight\n aria-hidden\n className=\"size-4 shrink-0 text-placeholder rtl:-scale-x-100\"\n />\n ) : (\n <ChevronDown\n aria-hidden\n className={cn(\n \"size-4 shrink-0 text-placeholder transition-transform\",\n expanded && \"rotate-180\",\n )}\n />\n ))}\n </>\n );\n // The long press rides the card itself; the click it leaves behind is swallowed on\n // the `<li>` below, in the capture phase, before the card (or RowLink) sees it.\n // Attached whenever the table selects, the hook's `enabled` deciding whether a press\n // arms: its pointerdown also clears a stale swallow latch, which must happen in the\n // mode too, or a long press whose click never came would eat the first toggle.\n //\n // A press that starts on a control in a cell (or in a portal one opened) is that\n // control's, not a way into the mode (keksdose live #428): it only clears the latch.\n const press = select\n ? {\n ...longPress.handlers,\n onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {\n if (fromOutside(e.target, e.currentTarget) || fromOwnControl(e.target, e.currentTarget)) {\n longPress.consumeClick();\n return;\n }\n longPress.handlers.onPointerDown(e);\n },\n onContextMenu: (e: ReactMouseEvent<HTMLElement>) => {\n if (fromOutside(e.target, e.currentTarget) || fromOwnControl(e.target, e.currentTarget)) return;\n longPress.handlers.onContextMenu(e);\n },\n }\n : undefined;\n const body = selecting ? (\n // In the mode a card is a toggle — kk's register row: `role=\"button\"` with\n // `aria-pressed`, the box beside it only a picture of that state (the card is the\n // one control, so the two can never fight or fire twice).\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=\"button\",\n tabIndex 0 and Enter/Space are all here; the rule cannot follow the conditional role. */\n <div\n role={select!.selectable ? \"button\" : undefined}\n tabIndex={select!.selectable ? 0 : undefined}\n aria-pressed={select!.selectable ? select!.selected : undefined}\n aria-disabled={select!.selectable ? undefined : true}\n data-card-selecting=\"\"\n {...press}\n onClick={(e) => {\n if (!select!.selectable || fromOutside(e.target, e.currentTarget)) return;\n // A tap on a control in a cell is that control's (keksdose live #428).\n if (fromOwnControl(e.target, e.currentTarget)) return;\n select!.onToggle(!select!.selected);\n }}\n onKeyDown={(e) => {\n if (!select!.selectable || e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n select!.onToggle(!select!.selected);\n }\n }}\n className={cardClass}\n >\n <Checkbox\n readOnly\n tabIndex={-1}\n aria-hidden\n checked={select!.selected}\n disabled={!select!.selectable}\n className=\"pointer-events-none shrink-0 self-center\"\n inputClassName=\"pointer-events-none\"\n />\n {cardInner}\n </div>\n ) : interactive && href ? (\n // A card with a URL of its own is a LINK, not a div wearing role=\"button\"\n // (feedback #451): the phone's long-press \"open in new tab\" and a screen\n // reader's link semantics both come free, and the fake role goes away.\n <RowLink\n href={href}\n {...press}\n onActivate={() => onRowClick!(row)}\n aria-expanded={opensRow ? expanded : undefined}\n className={cardClass}\n // Enter already activates a link natively, and that routes through\n // RowLink's own onClick — handling it here too would open the row twice.\n // Space is the only key left, and only so it opens the row instead of\n // scrolling the list.\n onKeyDown={(e) => {\n if (e.key !== \" \") return;\n e.preventDefault();\n onRowClick!(row);\n }}\n >\n {cardInner}\n </RowLink>\n ) : (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- the handlers are\n only attached when `interactive`, and then so are role=\"button\", tabIndex 0 and the\n Enter/Space handler below; the rule cannot follow the conditional role. */\n <div\n {...press}\n role={interactive ? \"button\" : undefined}\n tabIndex={interactive ? 0 : undefined}\n onClick={\n interactive\n ? (e) => {\n if (fromOutside(e.target, e.currentTarget)) return;\n // A tap on a control in a cell (a select, a button) is that\n // control's, as on the desktop `<tr>` (keksdose live #428).\n if (fromOwnControl(e.target, e.currentTarget)) return;\n onRowClick!(row);\n }\n : undefined\n }\n onKeyDown={\n interactive\n ? (e) => {\n // A key press inside a nested control is that control's.\n if (e.target !== e.currentTarget) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onRowClick!(row);\n }\n }\n : undefined\n }\n aria-expanded={opensRow ? expanded : undefined}\n className={cardClass}\n >\n {cardInner}\n </div>\n );\n return (\n <li\n className={cn(tint)}\n {...cleanAttrs(rowAttributes?.(row))}\n data-selected={selecting && select!.selected ? \"\" : undefined}\n onClickCapture={\n select\n ? (e) => {\n if (!longPress.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }\n : undefined\n }\n >\n {swipeEnabled ? (\n <SwipeableRow enabled left={swipe!.left} right={swipe!.right}>\n {body}\n </SwipeableRow>\n ) : (\n body\n )}\n {expansion && !mobileExpandAsDialog && (\n <div\n className={cn(\n \"bg-surface-2 border-t border-subtle\",\n compact ? \"px-3 py-2\" : \"px-4 py-3\",\n )}\n >\n {expansion}\n </div>\n )}\n </li>\n );\n}\n\n// ---------- Phone selection bar (0.40, keksdose live #421) ----------\n\n/**\n * The card list's way into and out of selection mode for a keyboard and a screen\n * reader — the long press is a touch gesture, and a gesture alone is no path at all.\n *\n * Outside the mode it is one button, \"Select rows\", visually hidden until it takes\n * focus (SwipeableRow's keyboard buttons, the same rule): the card list keeps the look\n * kk's register has, where the long press is the way in, and Tab or a screen reader's\n * cursor still finds it. In the mode it is a visible row: \"Select all rows\" — the\n * desktop header's box — and Cancel, which clears the selection and leaves, as the\n * bulk bar's Clear does. The owner's bulk bar is the owner's, as on desktop: it\n * appears because the selection is no longer empty.\n */\nexport function MobileSelectBar({\n active,\n allSelected,\n someSelected,\n compact,\n labels,\n onEnter,\n onToggleAll,\n onCancel,\n}: {\n active: boolean;\n allSelected: boolean;\n someSelected: boolean;\n compact: boolean;\n labels: DataTableLabels;\n onEnter: () => void;\n onToggleAll: (checked: boolean) => void;\n onCancel: () => void;\n}) {\n // The control that was pressed unmounts with the mode it switched, so focus is\n // handed across: into the bar's box on the way in, back to the button on the way out.\n const handOff = useRef(false);\n const allBox = useRef<HTMLInputElement>(null);\n const enterButton = useRef<HTMLButtonElement>(null);\n useEffect(() => {\n if (!handOff.current) return;\n handOff.current = false;\n (active ? allBox.current : enterButton.current)?.focus();\n }, [active]);\n if (!active)\n return (\n <button\n ref={enterButton}\n type=\"button\"\n data-table-mobile-select=\"\"\n onClick={() => {\n handOff.current = true;\n onEnter();\n }}\n className={cn(\n \"sr-only rounded-md text-sm font-medium text-secondary\",\n // Padding only once shown: unconditional, it beats sr-only's `padding: 0`\n // (SwipeableRow's 0.32.1 note).\n \"focus:not-sr-only focus:m-2 focus:inline-flex focus:px-2 focus:py-1\",\n FOCUS_RING,\n )}\n >\n {labels.selectRows}\n </button>\n );\n return (\n <div\n data-table-mobile-select=\"\"\n className={cn(\n \"flex flex-wrap items-center justify-between gap-2 border-b border-subtle\",\n compact ? \"px-3 py-1.5\" : \"px-4 py-2\",\n )}\n >\n <Checkbox\n ref={allBox}\n label={labels.selectAllRows}\n checked={allSelected}\n indeterminate={someSelected && !allSelected}\n onChange={(e) => onToggleAll(e.target.checked)}\n />\n <button\n type=\"button\"\n onClick={() => {\n handOff.current = true;\n onCancel();\n }}\n className={cn(\"rounded-md px-2 py-1 text-sm text-muted hover:text-secondary\", FOCUS_RING)}\n >\n {labels.cancelSelection}\n </button>\n </div>\n );\n}\n\n// ---------- Mobile filter sheet ----------\n\n/**\n * Filter access for the mobile card list (feedback #299). The desktop table\n * houses per-column filters in its header; the card list has no header, so the\n * same filters were unreachable on phones. This renders a compact \"Filters\" bar\n * that opens a bottom sheet listing each filterable column as a collapsible\n * section, reusing the very same {@link FilterPopover} and filter state the\n * desktop table drives — so it filters identically and stays URL-synced.\n *\n * The sheet is a {@link DialogFrame} (round 0.34 BUG-7b = DUP-6). It was the last\n * hand-rolled modal in the kit: `role=\"dialog\" aria-modal=\"true\"` on the backdrop, with\n * copies of the scroll lock, the backdrop close and the Back entry — and no name, no\n * focus moved in, no trap and no Escape, so a reader was told the page was hidden while\n * focus stayed on it. On a phone `Modal` is a bottom sheet already; it now brings all\n * of that, and the exit motion, from the one place every other dialog gets it.\n */\nexport function MobileFilters<T>({\n columns,\n filters,\n selectOptionsByKey,\n isServer,\n hasFilterCallback,\n onSetFilter,\n onClearFilter,\n onClearAll,\n labels,\n locale,\n}: {\n columns: DataTableColumn<T>[];\n filters: FilterState;\n selectOptionsByKey: Record<string, { value: string; label: string }[]>;\n isServer: boolean;\n hasFilterCallback: boolean;\n onSetFilter: (key: string, value: FilterValue) => void;\n onClearFilter: (key: string) => void;\n onClearAll: () => void;\n labels: DataTableLabels;\n locale?: string;\n}) {\n const [open, setOpen] = useState(false);\n // The sheet is portalled to <body>, out from under whatever `dir` the table sits\n // in, so it carries the trigger's direction with it — read at the moment of opening,\n // from the element the user actually pressed.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n const [expanded, setExpanded] = useState<string | null>(null);\n\n const filterable = useMemo(\n () =>\n columns\n .map((col) => ({ col, filter: !isServer || hasFilterCallback ? resolveFilter(col) : null }))\n .filter((x): x is { col: DataTableColumn<T>; filter: ColumnFilter<T> } => !!x.filter),\n [columns, isServer, hasFilterCallback],\n );\n\n if (filterable.length === 0) return null;\n\n const activeCount = filterable.filter(({ col }) => {\n const s = filters[col.key];\n return s && isFilterActive(s);\n }).length;\n\n return (\n <div className=\"flex items-center justify-between gap-2 border-b border-subtle px-4 py-2\">\n <button\n type=\"button\"\n onClick={(e) => {\n setDir(dirOf(e.currentTarget));\n setOpen(true);\n }}\n className=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-secondary hover:bg-hover\"\n >\n <Filter className=\"size-4\" />\n <span>{labels.filters}</span>\n {activeCount > 0 && (\n <span className=\"inline-flex min-w-4 items-center justify-center rounded-full bg-info-soft px-1 text-caption font-semibold text-info\">\n {activeCount}\n </span>\n )}\n </button>\n {activeCount > 0 && (\n <button\n type=\"button\"\n onClick={onClearAll}\n className=\"text-xs text-muted hover:text-secondary\"\n >\n {labels.clearAll}\n </button>\n )}\n {/* Escape, the backdrop, Back (Keksdose feedback #172: the filters are already\n in the URL, so closing never loses anything), the scroll lock, focus in and\n back to this bar's button — all `Modal`'s. `fullBleed` lets the sheet meet the\n screen's edges on a phone, as it always did. */}\n <DialogFrame\n open={open}\n onClose={() => setOpen(false)}\n title={labels.filters}\n closeButton\n closeLabel={labels.close}\n headerDivider\n fullBleed\n // Portalled to <body>, so the direction read from the button goes with it.\n dir={dir}\n className=\"max-h-[85vh] rounded-b-none md:rounded-b-lg\"\n // The sections run edge to edge with a rule between them, as before.\n bodyClassName=\"space-y-0 divide-y divide-subtle px-0 pt-0 pb-0\"\n actions={(close) => (\n <>\n <button\n type=\"button\"\n onClick={onClearAll}\n disabled={activeCount === 0}\n // `me-auto` keeps Clear all at the start and Done at the end.\n className=\"me-auto text-sm text-muted hover:text-secondary disabled:opacity-40\"\n >\n {labels.clearAll}\n </button>\n <button\n type=\"button\"\n onClick={close}\n className=\"rounded-md bg-inverse px-4 py-2 text-sm font-medium text-inverse\"\n >\n {labels.done}\n </button>\n </>\n )}\n >\n {filterable.map(({ col, filter }) => {\n const state = filters[col.key];\n const active = !!state && isFilterActive(state);\n const isItemOpen = expanded === col.key;\n return (\n <div key={col.key}>\n <button\n type=\"button\"\n onClick={() => setExpanded(isItemOpen ? null : col.key)}\n aria-expanded={isItemOpen}\n className=\"flex w-full items-center justify-between gap-2 px-4 py-3 text-start\"\n >\n <span className=\"flex items-center gap-2 text-sm font-medium text-secondary\">\n {col.header}\n {active && <span className=\"size-1.5 rounded-full bg-brand\" />}\n </span>\n <ChevronDown\n className={cn(\n \"size-4 shrink-0 text-placeholder transition-transform\",\n isItemOpen && \"rotate-180\",\n )}\n />\n </button>\n {isItemOpen && (\n <div className=\"px-4 pb-3\">\n <FilterPopover\n column={col}\n state={state ?? defaultFilterState(filter)}\n onChange={(next) => onSetFilter(col.key, next)}\n onClear={() => onClearFilter(col.key)}\n selectOptions={selectOptionsByKey[col.key] ?? []}\n labels={labels}\n locale={locale}\n />\n </div>\n )}\n </div>\n );\n })}\n </DialogFrame>\n </div>\n );\n}\n"],"mappings":"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","names":["Fragment"]}
|
|
@@ -64,6 +64,8 @@ export declare function resolveDataTableLabels(...layers: (LabelOverride<DataTab
|
|
|
64
64
|
* feedback page did, round its comments). The row owns the clicks on the row.
|
|
65
65
|
*/
|
|
66
66
|
export declare function fromOutside(target: EventTarget | null, row: HTMLElement): boolean;
|
|
67
|
+
/** Whether an event on `row` started on a control nested inside it. */
|
|
68
|
+
export declare function fromOwnControl(target: EventTarget | null, row: HTMLElement): boolean;
|
|
67
69
|
/**
|
|
68
70
|
* The anchor a linkable row is opened through (Keksdose feedback #451).
|
|
69
71
|
*
|
|
@@ -56,6 +56,12 @@ function resolveDataTableLabels(...layers) {
|
|
|
56
56
|
function fromOutside(target, row) {
|
|
57
57
|
return !(target instanceof Node) || !row.contains(target);
|
|
58
58
|
}
|
|
59
|
+
const OWN_CONTROL = 'a[href],button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="menuitem"],[role="option"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
|
|
60
|
+
function fromOwnControl(target, row) {
|
|
61
|
+
if (!(target instanceof Element)) return false;
|
|
62
|
+
const hit = target.closest(OWN_CONTROL);
|
|
63
|
+
return !!hit && hit !== row && row.contains(hit) && !hit.hasAttribute("data-row-link");
|
|
64
|
+
}
|
|
59
65
|
const isPlainLeftClick = (e) => e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;
|
|
60
66
|
function RowLink({
|
|
61
67
|
href,
|
|
@@ -81,6 +87,7 @@ function RowLink({
|
|
|
81
87
|
e.stopPropagation();
|
|
82
88
|
return;
|
|
83
89
|
}
|
|
90
|
+
if (fromOwnControl(e.target, e.currentTarget)) return;
|
|
84
91
|
onActivate?.();
|
|
85
92
|
},
|
|
86
93
|
children
|
|
@@ -92,6 +99,7 @@ export {
|
|
|
92
99
|
cleanAttrs,
|
|
93
100
|
columnLabel,
|
|
94
101
|
fromOutside,
|
|
102
|
+
fromOwnControl,
|
|
95
103
|
logicalAlign,
|
|
96
104
|
resolveDataTableLabels,
|
|
97
105
|
splitCellProps
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/data-table-shared.tsx"],"sourcesContent":["import type { CSSProperties, ReactNode } from \"react\";\nimport { ROW_ACTIONS_KEY } from \"./data-table-row-actions\";\nimport { DEFAULT_DATA_TABLE_LABELS, type DataTableLabels } from \"./data-table-labels\";\nimport type { DataTableCellProps, DataTableColumn, DataTableHeadProps } from \"./data-table\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\n\n// What DataTable shares with its header (data-table-head.tsx), its column panel and its\n// phone layout (data-table-mobile.tsx): the cell-attribute and alignment rules, a\n// column's plain-text name, and the row link. Moved out of data-table.tsx unchanged\n// (refactor audit 2026-10, MOD-3), into a module of their own so none of those has to\n// import data-table.tsx at run time — which imports them. Since 0.35 also the table's\n// label resolver, which DataTable and its FilterPopover share and the barrel no longer\n// exports (round 0.35 R1).\n\n/**\n * Split a caller's cell attributes into what may reach the element and what the kit\n * owns. `role` and `data-col` are dropped outright (see `cellProps`); `className` and\n * `style` come back separately so the kit can merge rather than replace them.\n */\nexport function splitCellProps(\n props: DataTableCellProps | DataTableHeadProps | undefined,\n): { attrs: Record<string, unknown>; className?: string; style?: CSSProperties } {\n if (!props) return { attrs: {} };\n const {\n className,\n style,\n role: _role,\n \"data-col\": _col,\n ...attrs\n } = props as DataTableCellProps & { \"data-col\"?: unknown };\n return { attrs, className, style };\n}\n\n// A bare `text-right` / `text-left` token, with or without variant prefixes\n// (`md:text-right`). Deliberately NOT `text-right-…` or an arbitrary value.\nconst PHYSICAL_ALIGN = /(^|\\s)((?:[\\w-]+:)*)text-(left|right)(?=\\s|$)/g;\n\n/**\n * A column's `className`/`headClassName` with physical text alignment rewritten to\n * logical: `text-right` → `text-end`, `text-left` → `text-start`.\n *\n * Every numeric column written before 0.7.0 says `text-right`, and that is what it\n * MEANT: the end of the line, where digits line up. Rendered as written, a\n * right-to-left table puts those numbers on the far side from their own header's\n * sort button. Rewriting keeps each existing call site correct in both directions\n * with no change on its side; a caller who genuinely wants the physical side in RTL\n * too can say `ltr:text-right rtl:text-right`, which this leaves alone.\n */\nexport function logicalAlign(className: string | undefined): string | undefined {\n return className?.replace(\n PHYSICAL_ALIGN,\n (token: string, lead: string, variants: string, side: string) =>\n // Already scoped to one direction: that is a caller choosing the physical side.\n /(^|:)(ltr|rtl):/.test(variants)\n ? token\n : `${lead}${variants}text-${side === \"right\" ? \"end\" : \"start\"}`,\n );\n}\n\n/** Drop the undefined entries of a {@link DataTableProps.rowAttributes} map, so a\n * caller can write `{ \"data-x\": cond ? \"y\" : undefined }` and get no attribute\n * at all rather than the string \"undefined\" in the DOM. */\nexport function cleanAttrs(\n attrs: Record<string, string | undefined> | undefined,\n): Record<string, string> | undefined {\n if (!attrs) return undefined;\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(attrs)) if (v !== undefined) out[k] = v;\n return out;\n}\n\n/**\n * A column's name as a plain string.\n *\n * `header` is a ReactNode — an icon, a unit suffix, a tooltip-wrapped span — and\n * neither a live region nor the column-settings checklist can speak a React element.\n * The `key` fallback is the one the settings panel has always used; a column whose\n * header is not a string and whose key is not human-readable is a call site that\n * should pass a string header, and that is visible in what gets announced.\n */\nexport function columnLabel<T>(col: DataTableColumn<T>, labels?: DataTableLabels): string {\n if (col.key === ROW_ACTIONS_KEY && labels) return labels.actions;\n return col.headerText ?? (typeof col.header === \"string\" ? col.header : col.key);\n}\n\n/**\n * The table's labels: caller overrides merged onto the English defaults (presets merged\n * too, key by key, as the provider merges them — so the parameter is a\n * {@link LabelOverride}).\n *\n * DataTable's own, and FilterPopover's: public until 0.35, when it left the barrel for\n * DataTable's `labels` and the provider's `dataTable`, which it serves (round 0.35 R1,\n * refactor audit 2026-10 API-6a). It stays a resolver rather than `useKitLabels` for the\n * two keys it derives (`columnsCount`, `filterColumn`), which a plain merge would not.\n *\n * Lenient on purpose — a table with no labels at all still renders — so a translated app\n * finds out what fell back to English from `missingKitLabels(labels,\n * DEFAULT_UI_KIT_LABELS)` in its own test, where the answer is actionable.\n */\nexport function resolveDataTableLabels(\n ...layers: (LabelOverride<DataTableLabels> | undefined)[]\n): DataTableLabels {\n if (!layers.some(Boolean)) return DEFAULT_DATA_TABLE_LABELS;\n // The layers in order, the provider's then the prop's, later winning key by key. An\n // `undefined` key is skipped, as `useKitLabels` skips it, rather than blanking the word\n // under it: DataTable spread `{ ...provider, ...prop }`, so `labels={{ autoSize:\n // undefined }}` blanked the provider's word and the English one (round 0.35). `presets`\n // is a record and is merged the same way, so renaming one keeps the other ten.\n const partial: Partial<Omit<DataTableLabels, \"presets\">> = {};\n const presets = { ...DEFAULT_DATA_TABLE_LABELS.presets };\n for (const layer of layers) {\n for (const [key, value] of Object.entries(layer ?? {})) {\n if (value === undefined) continue;\n if (key !== \"presets\") Object.assign(partial, { [key]: value });\n else for (const [preset, text] of Object.entries(value)) if (text !== undefined) presets[preset] = text;\n }\n }\n const merged: DataTableLabels = { ...DEFAULT_DATA_TABLE_LABELS, ...partial, presets };\n // `columnsCount` was split out of `columns` + a template literal. Consumers have\n // been translating `columns` since 0.1, and backfilling the English default over\n // that would hand them \"Columns (4/9)\" back for having translated the key that\n // existed at the time. So the count is derived from the RESOLVED `columns` unless the\n // caller states the whole sentence, which is the escape hatch for a language where\n // the parenthetical does not fit.\n if (partial.columns !== undefined && partial.columnsCount === undefined) {\n merged.columnsCount = (visible, total) => `${merged.columns} (${visible}/${total})`;\n }\n // The same for `filterColumn` (0.34), from a translated `filter`: the app's own word\n // with the column, rather than English for having translated the key that existed.\n if (partial.filter !== undefined && partial.filterColumn === undefined) {\n merged.filterColumn = (column) => `${merged.filter}: ${column}`;\n }\n return merged;\n}\n\n/**\n * Whether a click that reached `row`'s handler did not start in the row's own DOM.\n *\n * React bubbles an event through the COMPONENT tree, portals included: a popover, a\n * menu or a dialog a cell (or the expansion under it) opens renders at `<body>` but\n * still reports its clicks to every React ancestor — the row among them. So a click\n * in a cell's portalled date picker opened the row it came from, and a page wrapped\n * such content in `<div onClick={(e) => e.stopPropagation()}>` to stop it (kastlan's\n * feedback page did, round its comments). The row owns the clicks on the row.\n */\nexport function fromOutside(target: EventTarget | null, row: HTMLElement): boolean {\n return !(target instanceof Node) || !row.contains(target);\n}\n\n/** A click the APP owns. Anything else — middle, ⌘/Ctrl, Shift, Alt — belongs to\n * the browser, and the whole feature is not touching it. */\nconst isPlainLeftClick = (e: React.MouseEvent) =>\n e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;\n\n/**\n * The anchor a linkable row is opened through (Keksdose feedback #451).\n *\n * The plain left click is OURS: cancelled, then handed to the row's own handler, so\n * clicking a row still expands it in place instead of reloading the page. Every\n * other click — middle, ⌘/Ctrl, Shift, Alt — is left completely untouched, which is\n * the entire point of using an anchor rather than an `onAuxClick` + `window.open`:\n * that imitation buys back the middle click and still loses ⌘/Ctrl-click, \"copy link\n * address\", the status-bar preview and the phone's long-press menu.\n *\n * Modified clicks DO stop propagating: on a table with `selection`, ⌘/Ctrl-click and\n * Shift-click on a row mean \"select\" (feedback #289), and a row must not change its\n * selection while the browser is opening a tab. The rest of the row still selects —\n * the link is one cell, not the row.\n *\n * `draggable={false}` because an anchor otherwise hijacks a horizontal mouse drag as\n * a link-drag: that is both drag-to-select-text on the desktop table and the mouse\n * path through SwipeableRow's gesture on the mobile card.\n */\nexport function RowLink({\n href,\n onActivate,\n className,\n children,\n ...rest\n}: {\n href: string;\n /** Runs on a plain left click. Omit it where an ANCESTOR already handles the\n * click (the desktop `<tr>`), or the row would toggle twice. */\n onActivate?: () => void;\n className?: string;\n children: ReactNode;\n} & Omit<\n React.AnchorHTMLAttributes<HTMLAnchorElement>,\n \"href\" | \"className\" | \"children\" | \"onClick\" | \"onClickCapture\"\n>) {\n return (\n <a\n {...rest}\n href={href}\n draggable={false}\n className={className}\n // CAPTURE phase, and it has to be: on the mobile card the whole card is the\n // anchor, so a nested control sits INSIDE it — and the ones that matter\n // (the feedback row's status toggles) stop the click propagating so the row\n // won't also expand. A bubble-phase handler would never run for those, and\n // the browser would follow the link out from under the button press. Cancelling\n // here happens before any descendant can silence the event; `stopPropagation`\n // does not undo a `preventDefault`.\n onClickCapture={(e) => {\n // A click in a portal a descendant opened is not a click on the link: leave\n // it (and a link inside it) alone. See `fromOutside`.\n if (fromOutside(e.target, e.currentTarget)) return;\n if (isPlainLeftClick(e)) e.preventDefault();\n }}\n onClick={(e) => {\n if (fromOutside(e.target, e.currentTarget)) return;\n if (!isPlainLeftClick(e)) {\n e.stopPropagation();\n return;\n }\n // Already cancelled above; this phase only decides what opens. A nested\n // control that stopped propagation deliberately never reaches it, which is\n // exactly how the row behaved before it became a link.\n onActivate?.();\n }}\n >\n {children}\n </a>\n );\n}\n"],"mappings":";AA+LI;AA9LJ,SAAS,uBAAuB;AAChC,SAAS,iCAAuD;AAiBzD,SAAS,eACd,OAC+E;AAC/E,MAAI,CAAC,MAAO,QAAO,EAAE,OAAO,CAAC,EAAE;AAC/B,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,GAAG;AAAA,EACL,IAAI;AACJ,SAAO,EAAE,OAAO,WAAW,MAAM;AACnC;AAIA,MAAM,iBAAiB;AAahB,SAAS,aAAa,WAAmD;AAC9E,SAAO,WAAW;AAAA,IAChB;AAAA,IACA,CAAC,OAAe,MAAc,UAAkB;AAAA;AAAA,MAE9C,kBAAkB,KAAK,QAAQ,IAC3B,QACA,GAAG,IAAI,GAAG,QAAQ,QAAQ,SAAS,UAAU,QAAQ,OAAO;AAAA;AAAA,EACpE;AACF;AAKO,SAAS,WACd,OACoC;AACpC,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,MAA8B,CAAC;AACrC,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,KAAK,EAAG,KAAI,MAAM,OAAW,KAAI,CAAC,IAAI;AAC1E,SAAO;AACT;AAWO,SAAS,YAAe,KAAyB,QAAkC;AACxF,MAAI,IAAI,QAAQ,mBAAmB,OAAQ,QAAO,OAAO;AACzD,SAAO,IAAI,eAAe,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS,IAAI;AAC9E;AAgBO,SAAS,0BACX,QACc;AACjB,MAAI,CAAC,OAAO,KAAK,OAAO,EAAG,QAAO;AAMlC,QAAM,UAAqD,CAAC;AAC5D,QAAM,UAAU,EAAE,GAAG,0BAA0B,QAAQ;AACvD,aAAW,SAAS,QAAQ;AAC1B,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,SAAS,CAAC,CAAC,GAAG;AACtD,UAAI,UAAU,OAAW;AACzB,UAAI,QAAQ,UAAW,QAAO,OAAO,SAAS,EAAE,CAAC,GAAG,GAAG,MAAM,CAAC;AAAA,UACzD,YAAW,CAAC,QAAQ,IAAI,KAAK,OAAO,QAAQ,KAAK,EAAG,KAAI,SAAS,OAAW,SAAQ,MAAM,IAAI;AAAA,IACrG;AAAA,EACF;AACA,QAAM,SAA0B,EAAE,GAAG,2BAA2B,GAAG,SAAS,QAAQ;AAOpF,MAAI,QAAQ,YAAY,UAAa,QAAQ,iBAAiB,QAAW;AACvE,WAAO,eAAe,CAAC,SAAS,UAAU,GAAG,OAAO,OAAO,KAAK,OAAO,IAAI,KAAK;AAAA,EAClF;AAGA,MAAI,QAAQ,WAAW,UAAa,QAAQ,iBAAiB,QAAW;AACtE,WAAO,eAAe,CAAC,WAAW,GAAG,OAAO,MAAM,KAAK,MAAM;AAAA,EAC/D;AACA,SAAO;AACT;AAYO,SAAS,YAAY,QAA4B,KAA2B;AACjF,SAAO,EAAE,kBAAkB,SAAS,CAAC,IAAI,SAAS,MAAM;AAC1D;AAIA,MAAM,mBAAmB,CAAC,MACxB,EAAE,WAAW,KAAK,CAAC,EAAE,WAAW,CAAC,EAAE,WAAW,CAAC,EAAE,YAAY,CAAC,EAAE;AAqB3D,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAUG;AACD,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ;AAAA,MACA,WAAW;AAAA,MACX;AAAA,MAQA,gBAAgB,CAAC,MAAM;AAGrB,YAAI,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAG;AAC5C,YAAI,iBAAiB,CAAC,EAAG,GAAE,eAAe;AAAA,MAC5C;AAAA,MACA,SAAS,CAAC,MAAM;AACd,YAAI,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAG;AAC5C,YAAI,CAAC,iBAAiB,CAAC,GAAG;AACxB,YAAE,gBAAgB;AAClB;AAAA,QACF;AAIA,qBAAa;AAAA,MACf;AAAA,MAEC;AAAA;AAAA,EACH;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/data-table-shared.tsx"],"sourcesContent":["import type { CSSProperties, ReactNode } from \"react\";\nimport { ROW_ACTIONS_KEY } from \"./data-table-row-actions\";\nimport { DEFAULT_DATA_TABLE_LABELS, type DataTableLabels } from \"./data-table-labels\";\nimport type { DataTableCellProps, DataTableColumn, DataTableHeadProps } from \"./data-table\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\n\n// What DataTable shares with its header (data-table-head.tsx), its column panel and its\n// phone layout (data-table-mobile.tsx): the cell-attribute and alignment rules, a\n// column's plain-text name, and the row link. Moved out of data-table.tsx unchanged\n// (refactor audit 2026-10, MOD-3), into a module of their own so none of those has to\n// import data-table.tsx at run time — which imports them. Since 0.35 also the table's\n// label resolver, which DataTable and its FilterPopover share and the barrel no longer\n// exports (round 0.35 R1).\n\n/**\n * Split a caller's cell attributes into what may reach the element and what the kit\n * owns. `role` and `data-col` are dropped outright (see `cellProps`); `className` and\n * `style` come back separately so the kit can merge rather than replace them.\n */\nexport function splitCellProps(\n props: DataTableCellProps | DataTableHeadProps | undefined,\n): { attrs: Record<string, unknown>; className?: string; style?: CSSProperties } {\n if (!props) return { attrs: {} };\n const {\n className,\n style,\n role: _role,\n \"data-col\": _col,\n ...attrs\n } = props as DataTableCellProps & { \"data-col\"?: unknown };\n return { attrs, className, style };\n}\n\n// A bare `text-right` / `text-left` token, with or without variant prefixes\n// (`md:text-right`). Deliberately NOT `text-right-…` or an arbitrary value.\nconst PHYSICAL_ALIGN = /(^|\\s)((?:[\\w-]+:)*)text-(left|right)(?=\\s|$)/g;\n\n/**\n * A column's `className`/`headClassName` with physical text alignment rewritten to\n * logical: `text-right` → `text-end`, `text-left` → `text-start`.\n *\n * Every numeric column written before 0.7.0 says `text-right`, and that is what it\n * MEANT: the end of the line, where digits line up. Rendered as written, a\n * right-to-left table puts those numbers on the far side from their own header's\n * sort button. Rewriting keeps each existing call site correct in both directions\n * with no change on its side; a caller who genuinely wants the physical side in RTL\n * too can say `ltr:text-right rtl:text-right`, which this leaves alone.\n */\nexport function logicalAlign(className: string | undefined): string | undefined {\n return className?.replace(\n PHYSICAL_ALIGN,\n (token: string, lead: string, variants: string, side: string) =>\n // Already scoped to one direction: that is a caller choosing the physical side.\n /(^|:)(ltr|rtl):/.test(variants)\n ? token\n : `${lead}${variants}text-${side === \"right\" ? \"end\" : \"start\"}`,\n );\n}\n\n/** Drop the undefined entries of a {@link DataTableProps.rowAttributes} map, so a\n * caller can write `{ \"data-x\": cond ? \"y\" : undefined }` and get no attribute\n * at all rather than the string \"undefined\" in the DOM. */\nexport function cleanAttrs(\n attrs: Record<string, string | undefined> | undefined,\n): Record<string, string> | undefined {\n if (!attrs) return undefined;\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(attrs)) if (v !== undefined) out[k] = v;\n return out;\n}\n\n/**\n * A column's name as a plain string.\n *\n * `header` is a ReactNode — an icon, a unit suffix, a tooltip-wrapped span — and\n * neither a live region nor the column-settings checklist can speak a React element.\n * The `key` fallback is the one the settings panel has always used; a column whose\n * header is not a string and whose key is not human-readable is a call site that\n * should pass a string header, and that is visible in what gets announced.\n */\nexport function columnLabel<T>(col: DataTableColumn<T>, labels?: DataTableLabels): string {\n if (col.key === ROW_ACTIONS_KEY && labels) return labels.actions;\n return col.headerText ?? (typeof col.header === \"string\" ? col.header : col.key);\n}\n\n/**\n * The table's labels: caller overrides merged onto the English defaults (presets merged\n * too, key by key, as the provider merges them — so the parameter is a\n * {@link LabelOverride}).\n *\n * DataTable's own, and FilterPopover's: public until 0.35, when it left the barrel for\n * DataTable's `labels` and the provider's `dataTable`, which it serves (round 0.35 R1,\n * refactor audit 2026-10 API-6a). It stays a resolver rather than `useKitLabels` for the\n * two keys it derives (`columnsCount`, `filterColumn`), which a plain merge would not.\n *\n * Lenient on purpose — a table with no labels at all still renders — so a translated app\n * finds out what fell back to English from `missingKitLabels(labels,\n * DEFAULT_UI_KIT_LABELS)` in its own test, where the answer is actionable.\n */\nexport function resolveDataTableLabels(\n ...layers: (LabelOverride<DataTableLabels> | undefined)[]\n): DataTableLabels {\n if (!layers.some(Boolean)) return DEFAULT_DATA_TABLE_LABELS;\n // The layers in order, the provider's then the prop's, later winning key by key. An\n // `undefined` key is skipped, as `useKitLabels` skips it, rather than blanking the word\n // under it: DataTable spread `{ ...provider, ...prop }`, so `labels={{ autoSize:\n // undefined }}` blanked the provider's word and the English one (round 0.35). `presets`\n // is a record and is merged the same way, so renaming one keeps the other ten.\n const partial: Partial<Omit<DataTableLabels, \"presets\">> = {};\n const presets = { ...DEFAULT_DATA_TABLE_LABELS.presets };\n for (const layer of layers) {\n for (const [key, value] of Object.entries(layer ?? {})) {\n if (value === undefined) continue;\n if (key !== \"presets\") Object.assign(partial, { [key]: value });\n else for (const [preset, text] of Object.entries(value)) if (text !== undefined) presets[preset] = text;\n }\n }\n const merged: DataTableLabels = { ...DEFAULT_DATA_TABLE_LABELS, ...partial, presets };\n // `columnsCount` was split out of `columns` + a template literal. Consumers have\n // been translating `columns` since 0.1, and backfilling the English default over\n // that would hand them \"Columns (4/9)\" back for having translated the key that\n // existed at the time. So the count is derived from the RESOLVED `columns` unless the\n // caller states the whole sentence, which is the escape hatch for a language where\n // the parenthetical does not fit.\n if (partial.columns !== undefined && partial.columnsCount === undefined) {\n merged.columnsCount = (visible, total) => `${merged.columns} (${visible}/${total})`;\n }\n // The same for `filterColumn` (0.34), from a translated `filter`: the app's own word\n // with the column, rather than English for having translated the key that existed.\n if (partial.filter !== undefined && partial.filterColumn === undefined) {\n merged.filterColumn = (column) => `${merged.filter}: ${column}`;\n }\n return merged;\n}\n\n/**\n * Whether a click that reached `row`'s handler did not start in the row's own DOM.\n *\n * React bubbles an event through the COMPONENT tree, portals included: a popover, a\n * menu or a dialog a cell (or the expansion under it) opens renders at `<body>` but\n * still reports its clicks to every React ancestor — the row among them. So a click\n * in a cell's portalled date picker opened the row it came from, and a page wrapped\n * such content in `<div onClick={(e) => e.stopPropagation()}>` to stop it (kastlan's\n * feedback page did, round its comments). The row owns the clicks on the row.\n */\nexport function fromOutside(target: EventTarget | null, row: HTMLElement): boolean {\n return !(target instanceof Node) || !row.contains(target);\n}\n\n/**\n * What counts as a control of its own inside a clickable row: a click or key press\n * that lands on one of these belongs to it, not to the row's `onRowClick`. The row's\n * own link (`rowHref`, marked `data-row-link`) is the exception — it IS the row.\n */\nconst OWN_CONTROL =\n 'a[href],button,input,select,textarea,label,summary,[role=\"button\"],[role=\"link\"],[role=\"checkbox\"],[role=\"switch\"],[role=\"menuitem\"],[role=\"option\"],[contenteditable=\"\"],[contenteditable=\"true\"],[tabindex]:not([tabindex=\"-1\"])';\n\n/** Whether an event on `row` started on a control nested inside it. */\nexport function fromOwnControl(target: EventTarget | null, row: HTMLElement): boolean {\n if (!(target instanceof Element)) return false;\n const hit = target.closest(OWN_CONTROL);\n return !!hit && hit !== row && row.contains(hit) && !hit.hasAttribute(\"data-row-link\");\n}\n\n/** A click the APP owns. Anything else — middle, ⌘/Ctrl, Shift, Alt — belongs to\n * the browser, and the whole feature is not touching it. */\nconst isPlainLeftClick = (e: React.MouseEvent) =>\n e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;\n\n/**\n * The anchor a linkable row is opened through (Keksdose feedback #451).\n *\n * The plain left click is OURS: cancelled, then handed to the row's own handler, so\n * clicking a row still expands it in place instead of reloading the page. Every\n * other click — middle, ⌘/Ctrl, Shift, Alt — is left completely untouched, which is\n * the entire point of using an anchor rather than an `onAuxClick` + `window.open`:\n * that imitation buys back the middle click and still loses ⌘/Ctrl-click, \"copy link\n * address\", the status-bar preview and the phone's long-press menu.\n *\n * Modified clicks DO stop propagating: on a table with `selection`, ⌘/Ctrl-click and\n * Shift-click on a row mean \"select\" (feedback #289), and a row must not change its\n * selection while the browser is opening a tab. The rest of the row still selects —\n * the link is one cell, not the row.\n *\n * `draggable={false}` because an anchor otherwise hijacks a horizontal mouse drag as\n * a link-drag: that is both drag-to-select-text on the desktop table and the mouse\n * path through SwipeableRow's gesture on the mobile card.\n */\nexport function RowLink({\n href,\n onActivate,\n className,\n children,\n ...rest\n}: {\n href: string;\n /** Runs on a plain left click. Omit it where an ANCESTOR already handles the\n * click (the desktop `<tr>`), or the row would toggle twice. */\n onActivate?: () => void;\n className?: string;\n children: ReactNode;\n} & Omit<\n React.AnchorHTMLAttributes<HTMLAnchorElement>,\n \"href\" | \"className\" | \"children\" | \"onClick\" | \"onClickCapture\"\n>) {\n return (\n <a\n {...rest}\n href={href}\n draggable={false}\n className={className}\n // CAPTURE phase, and it has to be: on the mobile card the whole card is the\n // anchor, so a nested control sits INSIDE it — and the ones that matter\n // (the feedback row's status toggles) stop the click propagating so the row\n // won't also expand. A bubble-phase handler would never run for those, and\n // the browser would follow the link out from under the button press. Cancelling\n // here happens before any descendant can silence the event; `stopPropagation`\n // does not undo a `preventDefault`.\n onClickCapture={(e) => {\n // A click in a portal a descendant opened is not a click on the link: leave\n // it (and a link inside it) alone. See `fromOutside`.\n if (fromOutside(e.target, e.currentTarget)) return;\n if (isPlainLeftClick(e)) e.preventDefault();\n }}\n onClick={(e) => {\n if (fromOutside(e.target, e.currentTarget)) return;\n if (!isPlainLeftClick(e)) {\n e.stopPropagation();\n return;\n }\n // Already cancelled above; this phase only decides what opens. A nested\n // control that stopped propagation deliberately never reaches it, which is\n // exactly how the row behaved before it became a link — and one that did not\n // (a select in a phone card's cell, keksdose live #428) is still its own.\n if (fromOwnControl(e.target, e.currentTarget)) return;\n onActivate?.();\n }}\n >\n {children}\n </a>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
cleanAttrs,
|
|
13
13
|
columnLabel,
|
|
14
14
|
fromOutside,
|
|
15
|
+
fromOwnControl,
|
|
15
16
|
logicalAlign,
|
|
16
17
|
resolveDataTableLabels,
|
|
17
18
|
splitCellProps
|
|
@@ -39,12 +40,6 @@ const DEFAULT_DATA_TABLE_SORT_LABELS = {
|
|
|
39
40
|
sortDescending: DEFAULT_DATA_TABLE_LABELS.sortDescending
|
|
40
41
|
};
|
|
41
42
|
const flipDir = (d) => d === "asc" ? "desc" : "asc";
|
|
42
|
-
const OWN_CONTROL = 'a[href],button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="menuitem"],[role="option"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
|
|
43
|
-
function fromOwnControl(target, row) {
|
|
44
|
-
if (!(target instanceof Element)) return false;
|
|
45
|
-
const hit = target.closest(OWN_CONTROL);
|
|
46
|
-
return !!hit && hit !== row && row.contains(hit) && !hit.hasAttribute("data-row-link");
|
|
47
|
-
}
|
|
48
43
|
function moveRowFocus(from, key) {
|
|
49
44
|
if (!(key in ROW_NAV_KEYS)) return false;
|
|
50
45
|
const body = from.parentElement;
|