@eifi1/ui-kit 0.35.0 → 0.36.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 +35 -5
- package/dist/admin/role-select.d.ts +3 -2
- package/dist/admin/role-select.js +2 -0
- package/dist/admin/role-select.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +2 -2
- package/dist/components/autocomplete.js +2 -1
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-look.d.ts +1 -1
- package/dist/components/button-look.js +9 -4
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/checkbox-group.d.ts +5 -2
- package/dist/components/checkbox-group.js +15 -4
- package/dist/components/checkbox-group.js.map +1 -1
- package/dist/components/column-mapper.d.ts +5 -3
- package/dist/components/column-mapper.js +2 -0
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +2 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/country-select.d.ts +3 -3
- package/dist/components/country-select.js +3 -0
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/currency-select.d.ts +3 -2
- package/dist/components/currency-select.js +10 -1
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/date-field.d.ts +2 -1
- package/dist/components/date-field.js.map +1 -1
- package/dist/components/date-picker.js +3 -0
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/date-range-picker.js +3 -0
- package/dist/components/date-range-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +2 -2
- package/dist/components/entity-combobox.js +2 -1
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/field-parts.d.ts +66 -11
- package/dist/components/field-parts.js +32 -10
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +3 -2
- package/dist/components/field-strip.js +2 -1
- package/dist/components/field-strip.js.map +1 -1
- package/dist/components/field.d.ts +3 -2
- package/dist/components/field.js +24 -15
- package/dist/components/field.js.map +1 -1
- package/dist/components/help-popover.d.ts +9 -7
- package/dist/components/help-popover.js.map +1 -1
- package/dist/components/icon-picker.d.ts +3 -2
- package/dist/components/icon-picker.js +3 -0
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-entity-combobox.d.ts +2 -2
- package/dist/components/inline-entity-combobox.js +2 -1
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/input.d.ts +2 -2
- package/dist/components/input.js +2 -1
- package/dist/components/input.js.map +1 -1
- package/dist/components/month-picker.d.ts +2 -1
- package/dist/components/month-picker.js +8 -1
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +2 -2
- package/dist/components/multi-entity-combobox.js +2 -1
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +2 -2
- package/dist/components/multi-select.js +3 -0
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-input.d.ts +2 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numeric-field.d.ts +5 -2
- package/dist/components/numeric-field.js +4 -1
- package/dist/components/numeric-field.js.map +1 -1
- package/dist/components/one-time-code-input.d.ts +2 -1
- package/dist/components/one-time-code-input.js +2 -1
- package/dist/components/one-time-code-input.js.map +1 -1
- package/dist/components/phone-input.d.ts +1 -1
- package/dist/components/phone-input.js +4 -0
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/popover.js +1 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/select.d.ts +4 -3
- package/dist/components/select.js +2 -1
- package/dist/components/select.js.map +1 -1
- package/dist/components/slider.d.ts +3 -2
- package/dist/components/slider.js +7 -1
- package/dist/components/slider.js.map +1 -1
- package/dist/components/stat-tile.js +2 -11
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +3 -2
- package/dist/components/swatch-picker.js +3 -0
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/text-link.js +3 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/textarea.d.ts +2 -1
- package/dist/components/textarea.js +9 -3
- package/dist/components/textarea.js.map +1 -1
- package/dist/components/toggle-group.d.ts +2 -2
- package/dist/components/toggle-group.js +7 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +97 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +21 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +103 -2
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +95 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +96 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +95 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +96 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +95 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +13 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js +3 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +21 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/device-settings.d.ts +72 -0
- package/dist/lib/device-settings.js +64 -0
- package/dist/lib/device-settings.js.map +1 -0
- package/dist/lib/plain-text.d.ts +11 -0
- package/dist/lib/plain-text.js +16 -0
- package/dist/lib/plain-text.js.map +1 -0
- package/dist/lib/safe-storage.d.ts +6 -0
- package/dist/lib/safe-storage.js +11 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/notifications/notification-parts.d.ts +39 -0
- package/dist/notifications/notification-parts.js +47 -0
- package/dist/notifications/notification-parts.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +62 -0
- package/dist/notifications/notification-types-setting.js +192 -0
- package/dist/notifications/notification-types-setting.js.map +1 -0
- package/dist/notifications/notification-types.d.ts +75 -0
- package/dist/notifications/notification-types.js +2 -0
- package/dist/notifications/notification-types.js.map +1 -0
- package/dist/notifications/notifications-labels.d.ts +131 -0
- package/dist/notifications/notifications-labels.js +83 -0
- package/dist/notifications/notifications-labels.js.map +1 -0
- package/dist/notifications/push-device-setting.d.ts +37 -0
- package/dist/notifications/push-device-setting.js +164 -0
- package/dist/notifications/push-device-setting.js.map +1 -0
- package/dist/notifications/push-devices-setting.d.ts +44 -0
- package/dist/notifications/push-devices-setting.js +203 -0
- package/dist/notifications/push-devices-setting.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +47 -0
- package/dist/notifications/quiet-hours-setting.js +131 -0
- package/dist/notifications/quiet-hours-setting.js.map +1 -0
- package/dist/notifications/time-zone-select.d.ts +50 -0
- package/dist/notifications/time-zone-select.js +76 -0
- package/dist/notifications/time-zone-select.js.map +1 -0
- package/dist/notifications/unsubscribe-panel.d.ts +53 -0
- package/dist/notifications/unsubscribe-panel.js +139 -0
- package/dist/notifications/unsubscribe-panel.js.map +1 -0
- package/dist/notifications/use-push-device.d.ts +122 -0
- package/dist/notifications/use-push-device.js +230 -0
- package/dist/notifications/use-push-device.js.map +1 -0
- package/dist/rhf/fields.d.ts +26 -19
- package/dist/rhf/fields.js +51 -22
- package/dist/rhf/fields.js.map +1 -1
- package/dist/settings/device-reset-setting-labels.d.ts +3 -0
- package/dist/settings/device-reset-setting-labels.js +15 -0
- package/dist/settings/device-reset-setting-labels.js.map +1 -0
- package/dist/settings/device-reset-setting.d.ts +58 -0
- package/dist/settings/device-reset-setting.js +51 -0
- package/dist/settings/device-reset-setting.js.map +1 -0
- package/dist/shell/app-shell.js +5 -0
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/theme/contrast-tokens.d.ts +42 -4
- package/dist/theme/contrast-tokens.js +56 -9
- package/dist/theme/contrast-tokens.js.map +1 -1
- package/dist/theme/contrast.d.ts +4 -2
- package/dist/theme/contrast.js +13 -0
- package/dist/theme/contrast.js.map +1 -1
- package/dist/theme/palette-derive.d.ts +15 -1
- package/dist/theme/palette-derive.js +37 -8
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +2 -1
- package/dist/theme/palette-presets.js +5 -5
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +6 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/text-size.js +6 -0
- package/dist/theme/text-size.js.map +1 -1
- package/dist/theme/theme-store.js +6 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/role-select.tsx +4 -1
- package/src/components/amount-input.tsx +2 -1
- package/src/components/autocomplete.tsx +5 -2
- package/src/components/button-look.ts +15 -5
- package/src/components/checkbox-group.tsx +22 -4
- package/src/components/column-mapper.tsx +6 -2
- package/src/components/combobox.tsx +5 -2
- package/src/components/country-select.tsx +6 -1
- package/src/components/currency-select.tsx +12 -3
- package/src/components/date-field.tsx +2 -2
- package/src/components/date-picker.tsx +3 -0
- package/src/components/date-range-picker.tsx +3 -0
- package/src/components/entity-combobox.tsx +5 -2
- package/src/components/field-parts.tsx +107 -19
- package/src/components/field-strip.tsx +4 -3
- package/src/components/field.tsx +28 -15
- package/src/components/help-popover.tsx +9 -7
- package/src/components/icon-picker.tsx +8 -3
- package/src/components/inline-entity-combobox.tsx +5 -2
- package/src/components/input.tsx +4 -2
- package/src/components/month-picker.tsx +10 -3
- package/src/components/multi-entity-combobox.tsx +5 -2
- package/src/components/multi-select.tsx +6 -1
- package/src/components/number-input.tsx +2 -2
- package/src/components/numeric-field.ts +10 -2
- package/src/components/one-time-code-input.tsx +5 -3
- package/src/components/phone-input.tsx +4 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/select.tsx +6 -3
- package/src/components/slider.tsx +10 -3
- package/src/components/stat-tile.tsx +3 -18
- package/src/components/swatch-picker.tsx +9 -4
- package/src/components/text-link.tsx +3 -1
- package/src/components/textarea.tsx +11 -4
- package/src/components/toggle-group.tsx +21 -5
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +113 -0
- package/src/i18n/kit-labels.tsx +51 -1
- package/src/i18n/locales/en.ts +6 -0
- package/src/i18n/locales/es.ts +106 -0
- package/src/i18n/locales/fr.ts +106 -0
- package/src/i18n/locales/hu.ts +104 -0
- package/src/i18n/locales/it.ts +105 -0
- package/src/i18n/locales/zh.ts +96 -0
- package/src/i18n/review.ts +13 -0
- package/src/i18n/shared-labels.ts +2 -0
- package/src/index.ts +50 -0
- package/src/lib/device-settings.ts +146 -0
- package/src/lib/plain-text.ts +22 -0
- package/src/lib/safe-storage.ts +16 -0
- package/src/notifications/notification-parts.tsx +83 -0
- package/src/notifications/notification-types-setting.tsx +309 -0
- package/src/notifications/notification-types.ts +80 -0
- package/src/notifications/notifications-labels.ts +238 -0
- package/src/notifications/push-device-setting.tsx +224 -0
- package/src/notifications/push-devices-setting.tsx +306 -0
- package/src/notifications/quiet-hours-setting.tsx +185 -0
- package/src/notifications/time-zone-select.tsx +126 -0
- package/src/notifications/unsubscribe-panel.tsx +203 -0
- package/src/notifications/use-push-device.ts +392 -0
- package/src/rhf/fields.tsx +77 -29
- package/src/settings/device-reset-setting-labels.ts +20 -0
- package/src/settings/device-reset-setting.tsx +119 -0
- package/src/shell/app-shell.tsx +8 -0
- package/src/theme/contrast-tokens.ts +106 -12
- package/src/theme/contrast.ts +30 -4
- package/src/theme/palette-derive.ts +68 -7
- package/src/theme/palette-presets.ts +9 -6
- package/src/theme/palette-store.ts +12 -0
- package/src/theme/text-size.ts +8 -0
- package/src/theme/theme-store.ts +10 -0
- package/tokens.css +38 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/swatch-picker.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\nimport { Tooltip } from \"./tooltip\";\nimport { useCommitReason, type CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { LabelStrip, mergeDescribedBy, useFieldMessages } from \"./field-parts\";\nimport { DEFAULT_SWATCH_PICKER_LABELS } from \"./swatch-picker-labels\";\n\nexport interface SwatchPickerLabels {\n /** The \"no colour\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n}\n\n// Defined in ./swatch-picker-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_SWATCH_PICKER_LABELS };\n\nexport interface SwatchOption<T extends string> {\n value: T;\n /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an\n * inline background, so it does not have to be a class the kit's CSS knows.\n * Leave it out when `swatchClassName` paints the dot instead. For a colour that\n * must match a `StatusDot` / `Chip` hue elsewhere, `statusDotColor(\"teal\")` rather\n * than `\"var(--hue-teal)\"` written out — one table, so the two cannot drift. */\n color?: string;\n /** The colour's name, in the user's language. Shown in the bubble and read out;\n * required, because a swatch with no name is a colour only some people can read. */\n label: string;\n /** Classes for the swatch dot, for a palette that lives in classes rather than\n * values (a light/dark pair). An inline `color` would win over a class, so pass\n * one or the other. */\n swatchClassName?: string;\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Groceries\"). The tile wears a dot while it has one. */\n note?: string;\n /** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a\n * `disabledReason` the tile stays reachable instead and says why. */\n disabled?: boolean;\n /**\n * Why THIS colour cannot be chosen right now — the whole picker's `disabledReason`,\n * one tile at a time (the kit's later list after keksdose K3: a colour another\n * category already wears, a flag only an owner may set). {@link Button}'s rule: a tile\n * that is merely `disabled` is a dead square a keyboard never lands on, so nobody\n * learns why it is dimmed.\n *\n * The tile looks disabled but is `aria-disabled`, not `disabled`: Tab and the arrow\n * keys still reach it, its bubble shows the reason under its name, and the reason\n * describes it. Choosing it — a click, Space or Enter, or an arrow under automatic\n * activation — does nothing: the focus moves onto it and the checked colour stays\n * where it was, as under the whole-picker lock. Wins over `disabled`.\n */\n disabledReason?: ReactNode;\n}\n\n/**\n * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's\n * is omitted rather than shadowed, as on {@link ToggleGroup}.\n */\nexport interface SwatchPickerProps<T extends string>\n extends Omit<ComponentPropsWithRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\"> {\n options: SwatchOption<T>[];\n /** The chosen colour, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no colour\" tile whose value is `null`. Clearing a colour has to be\n * as reachable as setting one, and a radio cannot be unchecked by clicking it. */\n allowNone?: boolean;\n /**\n * \"Some of each\" — a bulk edit over rows whose colours differ. Nothing is checked\n * (not even \"none\", whatever `value` says), and the group is described as mixed.\n * Distinct from `value={null}`, which is an answer: \"no colour\".\n */\n mixed?: boolean;\n /** See {@link IconPickerProps.activation}. Default \"automatic\". */\n activation?: \"automatic\" | \"manual\";\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n /**\n * Classes for every tile's frame, merged after the kit's own — so a caller's\n * `size-*` beats `size`'s. For a tile that has to follow a container query or a\n * breakpoint (keksdose grows its tiles to 40px in a narrow card:\n * `tileClassName=\"@max-md:size-10\"`) without reaching into the kit's markup with an\n * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s\n * dimensions; the tile grows round it.\n */\n tileClassName?: string;\n disabled?: boolean;\n labels?: LabelOverride<SwatchPickerLabels>;\n /**\n * An 11px static label in a strip over the swatches, named by it (keksdose K2): the\n * transaction row's flag picker stands between labelled fields and hand-builds this\n * (`FieldLabel` + `pt-5`, transaction-flags.tsx:155) because the picker had no label.\n * The label sits on the same line as a field's own (`top-1`, `inset-x-3`); the\n * swatches start 20px down, below its whole line box — see `LABEL_STRIP_PAD`. With a\n * label, `className` styles the wrapper, as on a labelled {@link ToggleGroup}; an\n * explicit `aria-label` / `aria-labelledby` still names the group instead.\n */\n label?: ReactNode;\n /** A {@link FieldHint} beside the label. Only with `label`. {@link Select}'s rule since\n * 0.34 (DUP-2, refactor audit 2026-10): plain TEXT, and a FieldHint at Large and on a\n * touch screen (§4), is a caption under the swatches, describing the group. */\n hint?: ReactNode;\n /**\n * Why the colour cannot be changed right now — {@link Button}'s `disabledReason`, for\n * a picker that saves itself on change (keksdose K3, the flag picker in a row a\n * read-only viewer may see). The swatches stay reachable and the arrow keys still\n * walk them, but nothing reaches `onChange`; the reason is shown in the kit\n * {@link Tooltip} under the row (each swatch's own name bubble sits above it) and\n * describes the group. Wins over `disabled`.\n */\n disabledReason?: ReactNode;\n /** This picker COMMITS — choosing a colour saves it. Under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason, as {@link Button}'s `commit` does. */\n commit?: CommitScope;\n}\n\n/**\n * A row of colour swatches that is one radio group.\n *\n * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading\n * above it. Each swatch is named by its `label` and shows it in a bubble on hover and\n * focus, so the name is not the screen reader's alone.\n *\n * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one\n * while nothing is checked; the arrow keys move between swatches and, by default,\n * choose as they go; Home and End jump to the ends. Left and right follow the page's\n * direction.\n */\nexport function SwatchPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n size = \"md\",\n tileClassName,\n disabled = false,\n labels,\n label,\n hint,\n disabledReason: ownDisabledReason,\n commit,\n className,\n ...rest\n}: SwatchPickerProps<T>) {\n const text = useKitLabels(\"swatchPicker\", DEFAULT_SWATCH_PICKER_LABELS, labels);\n const mixedId = useId();\n const labelId = useId();\n const reasonId = useId();\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n const labelled = hasContent(label);\n // The label line's \"?\", or the caption under the swatches (DUP-2). Only with a label.\n // The caller's ids, the caption, then the group's state: mixed, locked.\n const messages = useFieldMessages({\n hint: labelled ? hint : undefined,\n describedBy: rest[\"aria-describedby\"],\n reasonId: mergeDescribedBy(mixed && mixedId, locked && reasonId),\n });\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...options.map((o) => ({\n key: o.value,\n label: o.label,\n note: o.note,\n disabled: o.disabled,\n disabledReason: o.disabledReason,\n })),\n ];\n const tiles = (\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n // A locked picker saves on change, so the change is swallowed — the arrow keys\n // still move the focus (and, automatic, would choose), and nothing is chosen. A\n // locked TILE is refused by the tiles themselves; asked again here so a reason\n // can never become a change, whatever the tiles do.\n onSelect={(next) => {\n if (!locked && !hasContent(next === null ? undefined : byValue.get(next)?.disabledReason)) onChange(next);\n }}\n activation={activation}\n // A reason wins over `disabled`: the tiles stay in the tab order.\n disabled={disabled && !locked}\n size={size}\n tileClassName={\n tileClassName || locked\n ? (_item, selected) =>\n cn(\n // The disabled tile's look, without its `disabled`: dimmed, a\n // not-allowed cursor, and no hover offer on a tile that cannot be\n // chosen. Not on the selected tile, whose frame IS its state.\n locked && \"cursor-not-allowed opacity-50\",\n locked && !selected && \"hover:border-subtle hover:bg-surface\",\n tileClassName,\n )\n : undefined\n }\n renderTile={(item) => {\n const opt = item.key === null ? undefined : byValue.get(item.key);\n return (\n <span\n aria-hidden\n // A hairline round the dot, drawn over whatever colour it is: a pale\n // yellow swatch on a white surface is otherwise a hole in the row.\n className={cn(\n \"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15\",\n TILE_SIZE[size].swatch,\n opt?.swatchClassName,\n )}\n style={{ background: opt?.color }}\n />\n );\n }}\n />\n );\n const group = (\n <div\n {...rest}\n role=\"radiogroup\"\n aria-labelledby={\n labelled && rest[\"aria-label\"] === undefined && rest[\"aria-labelledby\"] === undefined\n ? labelId\n : rest[\"aria-labelledby\"]\n }\n aria-disabled={disabled || locked || undefined}\n aria-describedby={messages.describedBy}\n className={cn(\"flex flex-wrap items-center gap-1.5\", !labelled && className)}\n >\n {locked ? (\n // Inside the group, round the tiles — not round the group, whose box is the\n // caller's row item. This row inherits the group's gap, wrap and alignment, so\n // the tiles sit exactly where they sit unlocked. Below the row: each tile's own\n // bubble (its colour's name) is above it.\n <Tooltip\n label={disabledReason}\n side=\"bottom\"\n className=\"flex min-w-0 flex-1 gap-[inherit] [align-items:inherit] [flex-wrap:inherit] [justify-content:inherit]\"\n >\n <>\n {tiles}\n <span id={reasonId} hidden>\n {disabledReason}\n </span>\n </>\n </Tooltip>\n ) : (\n tiles\n )}\n {/* `hidden`, not `sr-only`: a description is read from hidden text, and this\n way it takes no room in the row. */}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n </div>\n );\n if (!labelled) return group;\n return (\n <LabelStrip labelId={labelId} label={label} hint={messages.labelHint} disabled={disabled || locked} className={className}>\n {group}\n {messages.below}\n </LabelStrip>\n );\n}\n"],"mappings":";AAgNU,SAsCA,UAtCA,KAsCA,YAtCA;AAhNV,SAAS,OAAO,eAAe;AAE/B,SAAS,UAAU;AACnB,SAAS,oBAAwC;AACjD,SAAS,WAAW,sBAAsB;AAE1C,SAAS,eAAe;AACxB,SAAS,uBAAyC;AAClD,SAAS,kBAAkB;AAC3B,SAAS,YAAY,kBAAkB,wBAAwB;AAC/D,SAAS,oCAAoC;AA4HtC,SAAS,aAA+B;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,aAAa;AAAA,EACb,OAAO;AAAA,EACP;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AACvB,QAAM,OAAO,aAAa,gBAAgB,8BAA8B,MAAM;AAC9E,QAAM,UAAU,MAAM;AACtB,QAAM,UAAU,MAAM;AACtB,QAAM,WAAW,MAAM;AACvB,QAAM,iBAAiB,gBAAgB,QAAQ,iBAAiB;AAChE,QAAM,SAAS,WAAW,cAAc;AACxC,QAAM,WAAW,WAAW,KAAK;AAGjC,QAAM,WAAW,iBAAiB;AAAA,IAChC,MAAM,WAAW,OAAO;AAAA,IACxB,aAAa,KAAK,kBAAkB;AAAA,IACpC,UAAU,iBAAiB,SAAS,SAAS,UAAU,QAAQ;AAAA,EACjE,CAAC;AACD,QAAM,UAAU,QAAQ,MAAM,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC;AAClF,QAAM,QAAuB;AAAA,IAC3B,GAAI,YAAY,CAAC,EAAE,KAAK,MAAM,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC;AAAA,IACrD,GAAG,QAAQ,IAAI,CAAC,OAAO;AAAA,MACrB,KAAK,EAAE;AAAA,MACP,OAAO,EAAE;AAAA,MACT,MAAM,EAAE;AAAA,MACR,UAAU,EAAE;AAAA,MACZ,gBAAgB,EAAE;AAAA,IACpB,EAAE;AAAA,EACJ;AACA,QAAM,QACJ;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,SAAS,QAAQ,SAAY;AAAA,MAK7B,UAAU,CAAC,SAAS;AAClB,YAAI,CAAC,UAAU,CAAC,WAAW,SAAS,OAAO,SAAY,QAAQ,IAAI,IAAI,GAAG,cAAc,EAAG,UAAS,IAAI;AAAA,MAC1G;AAAA,MACA;AAAA,MAEA,UAAU,YAAY,CAAC;AAAA,MACvB;AAAA,MACA,eACE,iBAAiB,SACb,CAAC,OAAO,aACN;AAAA;AAAA;AAAA;AAAA,QAIE,UAAU;AAAA,QACV,UAAU,CAAC,YAAY;AAAA,QACvB;AAAA,MACF,IACF;AAAA,MAEN,YAAY,CAAC,SAAS;AACpB,cAAM,MAAM,KAAK,QAAQ,OAAO,SAAY,QAAQ,IAAI,KAAK,GAAG;AAChE,eACE;AAAA,UAAC;AAAA;AAAA,YACC,eAAW;AAAA,YAGX,WAAW;AAAA,cACT;AAAA,cACA,UAAU,IAAI,EAAE;AAAA,cAChB,KAAK;AAAA,YACP;AAAA,YACA,OAAO,EAAE,YAAY,KAAK,MAAM;AAAA;AAAA,QAClC;AAAA,MAEJ;AAAA;AAAA,EACF;AAEF,QAAM,QACJ;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,MAAK;AAAA,MACL,mBACE,YAAY,KAAK,YAAY,MAAM,UAAa,KAAK,iBAAiB,MAAM,SACxE,UACA,KAAK,iBAAiB;AAAA,MAE5B,iBAAe,YAAY,UAAU;AAAA,MACrC,oBAAkB,SAAS;AAAA,MAC3B,WAAW,GAAG,uCAAuC,CAAC,YAAY,SAAS;AAAA,MAE1E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAKC;AAAA,YAAC;AAAA;AAAA,cACC,OAAO;AAAA,cACP,MAAK;AAAA,cACL,WAAU;AAAA,cAEV,2CACG;AAAA;AAAA,gBACD,oBAAC,UAAK,IAAI,UAAU,QAAM,MACvB,0BACH;AAAA,iBACF;AAAA;AAAA,UACF;AAAA,YAEA;AAAA,QAID,SACC,oBAAC,UAAK,IAAI,SAAS,QAAM,MACtB,eAAK,OACR;AAAA;AAAA;AAAA,EAEJ;AAEF,MAAI,CAAC,SAAU,QAAO;AACtB,SACE,qBAAC,cAAW,SAAkB,OAAc,MAAM,SAAS,WAAW,UAAU,YAAY,QAAQ,WACjG;AAAA;AAAA,IACA,SAAS;AAAA,KACZ;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/swatch-picker.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TILE_SIZE, TileRadioGroup } from \"./tile-radio\";\nimport type { TileItem, TileSize } from \"./tile-radio\";\nimport { Tooltip } from \"./tooltip\";\nimport { useCommitReason, type CommitScope } from \"./write-lock\";\nimport { hasContent } from \"../lib/presence\";\nimport { LabelStrip, mergeDescribedBy, useFieldMessages, type FieldHelpProps } from \"./field-parts\";\nimport { DEFAULT_SWATCH_PICKER_LABELS } from \"./swatch-picker-labels\";\n\nexport interface SwatchPickerLabels {\n /** The \"no colour\" tile's name, when `allowNone` is set. */\n none: string;\n /** Describes the group while `mixed` is set — a bulk edit over rows that disagree. */\n mixed: string;\n}\n\n// Defined in ./swatch-picker-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_SWATCH_PICKER_LABELS };\n\nexport interface SwatchOption<T extends string> {\n value: T;\n /** Any CSS colour — a token (`var(--chart-3)`), a hex, an `oklch()`. Painted as an\n * inline background, so it does not have to be a class the kit's CSS knows.\n * Leave it out when `swatchClassName` paints the dot instead. For a colour that\n * must match a `StatusDot` / `Chip` hue elsewhere, `statusDotColor(\"teal\")` rather\n * than `\"var(--hue-teal)\"` written out — one table, so the two cannot drift. */\n color?: string;\n /** The colour's name, in the user's language. Shown in the bubble and read out;\n * required, because a swatch with no name is a colour only some people can read. */\n label: string;\n /** Classes for the swatch dot, for a palette that lives in classes rather than\n * values (a light/dark pair). An inline `color` would win over a class, so pass\n * one or the other. */\n swatchClassName?: string;\n /** A remark read after the name and shown under it in the bubble (\"used by\n * Groceries\"). The tile wears a dot while it has one. */\n note?: string;\n /** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a\n * `disabledReason` the tile stays reachable instead and says why. */\n disabled?: boolean;\n /**\n * Why THIS colour cannot be chosen right now — the whole picker's `disabledReason`,\n * one tile at a time (the kit's later list after keksdose K3: a colour another\n * category already wears, a flag only an owner may set). {@link Button}'s rule: a tile\n * that is merely `disabled` is a dead square a keyboard never lands on, so nobody\n * learns why it is dimmed.\n *\n * The tile looks disabled but is `aria-disabled`, not `disabled`: Tab and the arrow\n * keys still reach it, its bubble shows the reason under its name, and the reason\n * describes it. Choosing it — a click, Space or Enter, or an arrow under automatic\n * activation — does nothing: the focus moves onto it and the checked colour stays\n * where it was, as under the whole-picker lock. Wins over `disabled`.\n */\n disabledReason?: ReactNode;\n}\n\n/**\n * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's\n * is omitted rather than shadowed, as on {@link ToggleGroup}.\n */\nexport interface SwatchPickerProps<T extends string>\n extends Omit<ComponentPropsWithRef<\"div\">, \"onChange\" | \"children\" | \"defaultValue\">,\n FieldHelpProps {\n options: SwatchOption<T>[];\n /** The chosen colour, or `null` for none. */\n value: T | null;\n onChange: (value: T | null) => void;\n /** Lead with a \"no colour\" tile whose value is `null`. Clearing a colour has to be\n * as reachable as setting one, and a radio cannot be unchecked by clicking it. */\n allowNone?: boolean;\n /**\n * \"Some of each\" — a bulk edit over rows whose colours differ. Nothing is checked\n * (not even \"none\", whatever `value` says), and the group is described as mixed.\n * Distinct from `value={null}`, which is an answer: \"no colour\".\n */\n mixed?: boolean;\n /** See {@link IconPickerProps.activation}. Default \"automatic\". */\n activation?: \"automatic\" | \"manual\";\n /** 28 / 32 / 44px tiles. Default \"md\"; \"lg\" is the touch-target size. */\n size?: TileSize;\n /**\n * Classes for every tile's frame, merged after the kit's own — so a caller's\n * `size-*` beats `size`'s. For a tile that has to follow a container query or a\n * breakpoint (keksdose grows its tiles to 40px in a narrow card:\n * `tileClassName=\"@max-md:size-10\"`) without reaching into the kit's markup with an\n * `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s\n * dimensions; the tile grows round it.\n */\n tileClassName?: string;\n disabled?: boolean;\n labels?: LabelOverride<SwatchPickerLabels>;\n /**\n * An 11px static label in a strip over the swatches, named by it (keksdose K2): the\n * transaction row's flag picker stands between labelled fields and hand-builds this\n * (`FieldLabel` + `pt-5`, transaction-flags.tsx:155) because the picker had no label.\n * The label sits on the same line as a field's own (`top-1`, `inset-x-3`); the\n * swatches start 20px down, below its whole line box — see `LABEL_STRIP_PAD`. With a\n * label, `className` styles the wrapper, as on a labelled {@link ToggleGroup}; an\n * explicit `aria-label` / `aria-labelledby` still names the group instead.\n */\n label?: ReactNode;\n /** A {@link FieldHint} beside the label. Only with `label`. {@link Select}'s rule since\n * 0.34 (DUP-2, refactor audit 2026-10): plain TEXT, and a FieldHint at Large and on a\n * touch screen (§4), is a caption under the swatches, describing the group. */\n hint?: ReactNode;\n /**\n * Why the colour cannot be changed right now — {@link Button}'s `disabledReason`, for\n * a picker that saves itself on change (keksdose K3, the flag picker in a row a\n * read-only viewer may see). The swatches stay reachable and the arrow keys still\n * walk them, but nothing reaches `onChange`; the reason is shown in the kit\n * {@link Tooltip} under the row (each swatch's own name bubble sits above it) and\n * describes the group. Wins over `disabled`.\n */\n disabledReason?: ReactNode;\n /** This picker COMMITS — choosing a colour saves it. Under a locked\n * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's\n * reason, as {@link Button}'s `commit` does. */\n commit?: CommitScope;\n}\n\n/**\n * A row of colour swatches that is one radio group.\n *\n * Name the group with `aria-label` or `aria-labelledby` — usually the visible heading\n * above it. Each swatch is named by its `label` and shows it in a bubble on hover and\n * focus, so the name is not the screen reader's alone.\n *\n * Keyboard (the APG radio group): Tab reaches the checked swatch, or the first one\n * while nothing is checked; the arrow keys move between swatches and, by default,\n * choose as they go; Home and End jump to the ends. Left and right follow the page's\n * direction.\n */\nexport function SwatchPicker<T extends string>({\n options,\n value,\n onChange,\n allowNone = false,\n mixed = false,\n activation = \"automatic\",\n size = \"md\",\n tileClassName,\n disabled = false,\n labels,\n label,\n hint,\n help,\n disabledReason: ownDisabledReason,\n commit,\n className,\n ...rest\n}: SwatchPickerProps<T>) {\n const text = useKitLabels(\"swatchPicker\", DEFAULT_SWATCH_PICKER_LABELS, labels);\n const mixedId = useId();\n const labelId = useId();\n const reasonId = useId();\n const disabledReason = useCommitReason(commit, ownDisabledReason);\n const locked = hasContent(disabledReason);\n const labelled = hasContent(label);\n // The label line's \"?\", or the caption under the swatches (DUP-2). Only with a label,\n // the help's \"?\" too (0.36). The caller's ids, the caption, then the group's state:\n // mixed, locked.\n const messages = useFieldMessages({\n hint: labelled ? hint : undefined,\n help: labelled ? help : undefined,\n name: label,\n describedBy: rest[\"aria-describedby\"],\n reasonId: mergeDescribedBy(mixed && mixedId, locked && reasonId),\n });\n const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);\n const items: TileItem<T>[] = [\n ...(allowNone ? [{ key: null, label: text.none }] : []),\n ...options.map((o) => ({\n key: o.value,\n label: o.label,\n note: o.note,\n disabled: o.disabled,\n disabledReason: o.disabledReason,\n })),\n ];\n const tiles = (\n <TileRadioGroup\n items={items}\n checked={mixed ? undefined : value}\n // A locked picker saves on change, so the change is swallowed — the arrow keys\n // still move the focus (and, automatic, would choose), and nothing is chosen. A\n // locked TILE is refused by the tiles themselves; asked again here so a reason\n // can never become a change, whatever the tiles do.\n onSelect={(next) => {\n if (!locked && !hasContent(next === null ? undefined : byValue.get(next)?.disabledReason)) onChange(next);\n }}\n activation={activation}\n // A reason wins over `disabled`: the tiles stay in the tab order.\n disabled={disabled && !locked}\n size={size}\n tileClassName={\n tileClassName || locked\n ? (_item, selected) =>\n cn(\n // The disabled tile's look, without its `disabled`: dimmed, a\n // not-allowed cursor, and no hover offer on a tile that cannot be\n // chosen. Not on the selected tile, whose frame IS its state.\n locked && \"cursor-not-allowed opacity-50\",\n locked && !selected && \"hover:border-subtle hover:bg-surface\",\n tileClassName,\n )\n : undefined\n }\n renderTile={(item) => {\n const opt = item.key === null ? undefined : byValue.get(item.key);\n return (\n <span\n aria-hidden\n // A hairline round the dot, drawn over whatever colour it is: a pale\n // yellow swatch on a white surface is otherwise a hole in the row.\n className={cn(\n \"rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15\",\n TILE_SIZE[size].swatch,\n opt?.swatchClassName,\n )}\n style={{ background: opt?.color }}\n />\n );\n }}\n />\n );\n const group = (\n <div\n {...rest}\n role=\"radiogroup\"\n aria-labelledby={\n labelled && rest[\"aria-label\"] === undefined && rest[\"aria-labelledby\"] === undefined\n ? labelId\n : rest[\"aria-labelledby\"]\n }\n aria-disabled={disabled || locked || undefined}\n aria-describedby={messages.describedBy}\n className={cn(\"flex flex-wrap items-center gap-1.5\", !labelled && className)}\n >\n {locked ? (\n // Inside the group, round the tiles — not round the group, whose box is the\n // caller's row item. This row inherits the group's gap, wrap and alignment, so\n // the tiles sit exactly where they sit unlocked. Below the row: each tile's own\n // bubble (its colour's name) is above it.\n <Tooltip\n label={disabledReason}\n side=\"bottom\"\n className=\"flex min-w-0 flex-1 gap-[inherit] [align-items:inherit] [flex-wrap:inherit] [justify-content:inherit]\"\n >\n <>\n {tiles}\n <span id={reasonId} hidden>\n {disabledReason}\n </span>\n </>\n </Tooltip>\n ) : (\n tiles\n )}\n {/* `hidden`, not `sr-only`: a description is read from hidden text, and this\n way it takes no room in the row. */}\n {mixed && (\n <span id={mixedId} hidden>\n {text.mixed}\n </span>\n )}\n </div>\n );\n if (!labelled) return group;\n return (\n <LabelStrip labelId={labelId} label={label} hint={messages.labelHint} disabled={disabled || locked} className={className}>\n {group}\n {messages.below}\n </LabelStrip>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -37,7 +37,9 @@ function RenderedKitLink({ render, props }) {
|
|
|
37
37
|
return render(props);
|
|
38
38
|
}
|
|
39
39
|
const TONE = {
|
|
40
|
-
brand
|
|
40
|
+
// Under More contrast the brand as text, `--brand-muted`, at rest and hovered (0.36,
|
|
41
|
+
// colour-roles §13.3 decision 8): `--brand` is a mark's colour, solved to 3:1.
|
|
42
|
+
brand: "text-brand hover:text-[var(--brand-hover)] contrast:text-brand-muted contrast:hover:text-brand-muted",
|
|
41
43
|
muted: "text-muted hover:text-primary",
|
|
42
44
|
danger: "text-danger hover:text-[var(--danger-hover)]",
|
|
43
45
|
inherit: "",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/text-link.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { ExternalLink } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent } from \"../i18n/kit-labels\";\nimport { documentNavigation } from \"../lib/document-navigation\";\n\n/* ── Choosing what draws a link ──────────────────────────────────────────── */\n\n/**\n * An `href` that leaves the app: a scheme (`https:`, `mailto:`, `tel:`) or a\n * protocol-relative `//host`. A router's link is for paths inside the app, so the\n * provider's `linkComponent` is never handed one of these — they stay a plain `<a>`.\n *\n * NOT external, and so handed to the router link: a path (`/x`, `x`, `?q=1`) and the\n * hash-router form `#/x` — that IS a route in a `HashRouter` app, which maps it to\n * `to=\"/x\"`. A plain in-page anchor (`#section`) is not external either, but it is not\n * a route: see {@link isInPageAnchor}.\n */\nexport function isExternalHref(href: string): boolean {\n return /^(?:[a-z][a-z\\d+.-]*:|\\/\\/)/i.test(href);\n}\n\n/**\n * An `href` that only scrolls the current page: `#section` (or a bare `#`). The\n * hash-router form `#/x` is a ROUTE, not an anchor, and is not one of these. A router\n * link must not get an anchor — under a `HashRouter` `#section` would navigate to the\n * path `/section` — so every kit link draws it as a plain `<a>`.\n */\nexport function isInPageAnchor(href: string): boolean {\n return href.startsWith(\"#\") && !href.startsWith(\"#/\");\n}\n\n/**\n * Whether the provider's `linkComponent` (or a kit component's router fallback) draws\n * `href`: an in-app path, `#/x` included — not an external `href`\n * ({@link isExternalHref}) and not an in-page `#anchor` ({@link isInPageAnchor}).\n */\nexport function isRoutableHref(href: string): boolean {\n return !isExternalHref(href) && !isInPageAnchor(href);\n}\n\n/**\n * What draws a kit link to `href`: the component's own `renderLink` when it was given\n * one (it always wins — the caller asked for it by name), else the\n * `<UiKitProvider linkComponent>` for a routable `href` ({@link isRoutableHref}), else\n * `undefined` — a plain `<a>`. The one rule every kit link follows: `https:` /\n * `mailto:` / `//host` and `#section` stay plain anchors; `/x`, `x` and a hash-router\n * `#/x` go to the router link. Every kit link props type is a subset of the anchor\n * attributes `KitLinkProps` describes, which is what makes the cast sound.\n * @internal Shared by the kit's link-bearing components; not part of the barrel.\n */\nexport function pickLinkRenderer<P>(\n own: ((props: P) => ReactElement) | undefined,\n kitLink: KitLinkComponent | undefined,\n href: string | undefined,\n): ((props: P) => ReactElement) | undefined {\n if (own) return own;\n if (!kitLink || href === undefined || !isRoutableHref(href)) return undefined;\n return kitLink as unknown as (props: P) => ReactElement;\n}\n\n/** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /\n * Shift / Alt (new tab, new window, download), and not one a handler already took. */\nfunction isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !event.defaultPrevented &&\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey\n );\n}\n\n/**\n * The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).\n *\n * A router link replaces the history entry itself (it is handed `replace`, see\n * {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the\n * browser's own navigation always pushes. So a plain click is taken over — the caller's\n * `onClick` runs first and may still `preventDefault` — and becomes\n * `location.replace(href)`: the same document load, with Back skipping the one-shot URL.\n * That is the case of `replace` together with `reloadDocument`, or an app with no\n * `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left\n * to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes\n * back untouched.\n * @internal\n */\nexport function replacingClick(\n onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,\n href: string,\n replace: boolean | undefined,\n target?: string,\n): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {\n if (!replace || (target !== undefined && target !== \"\" && target !== \"_self\")) return onClick;\n return (event) => {\n onClick?.(event);\n if (!isPlainNavigationClick(event)) return;\n event.preventDefault();\n documentNavigation.replace(href);\n };\n}\n\n/**\n * What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else\n * nothing at all — so a provider link that spreads its props onto a DOM `<a>` never\n * receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).\n * @internal\n */\nexport function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {\n return replace ? { replace: true } : {};\n}\n\n/**\n * Draws `props` through `render` as a component of its own, so a router link's hooks\n * belong to it and not to the kit component calling it — for the kit components that\n * call their own `renderLink` inline and reach the provider's link through here.\n * @internal\n */\nexport function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {\n return render(props);\n}\n\n/* ── TextLink ─────────────────────────────────────────────────────────────── */\n\n/**\n * `brand` (default) for a link in running text, `muted` for footer and meta links\n * that should not compete with the content, `danger` for a destructive one (\"Leave\n * group\"), `inherit` for a link that takes its surroundings' colour.\n *\n * `primary` and `secondary` are the body and the secondary text colour, UNDERLINED\n * (keksdose F3): a link that must read as part of the text around it — a name in a\n * list row, a reference in a card's meta line — where `brand` repaints it in the accent\n * and `muted` is a step too light to read as content. `secondary` darkens to the body\n * colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its\n * underline thickens instead.\n *\n * `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside\n * a `<Tone tone=\"warning\">` sentence or a warning banner, which `brand` would paint in a\n * second hue and `inherit` would leave indistinguishable from the sentence. Like\n * `primary`, it keeps its colour on hover and thickens the underline.\n *\n * The three colours that sit IN the text default to `underline=\"always\"`: with the\n * colour of their surroundings, the underline is the only thing that says \"link\"\n * (WCAG 1.4.1). An explicit `underline` still wins.\n */\nexport type TextLinkTone = \"brand\" | \"muted\" | \"danger\" | \"inherit\" | \"primary\" | \"secondary\" | \"warning\";\n\n/** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under\n * the pointer — the idiom all three apps draw. `always` (the default for `primary`,\n * `secondary`, `warning`) for a link inside prose, where colour alone must not be the\n * only mark (WCAG 1.4.1). `none` where the context already says \"link\" (a footer nav). */\nexport type TextLinkUnderline = \"hover\" | \"always\" | \"none\";\n\n/** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */\nexport type TextLinkCurrent = \"page\" | \"step\" | \"location\" | \"date\" | \"time\" | \"true\";\n\n/** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is\n * handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface TextLinkRenderProps {\n href: string;\n /** The link's look — keep it, or the tone, underline and focus ring go. */\n className: string;\n children: ReactNode;\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: TextLinkCurrent;\n id?: string;\n title?: string;\n /** Present, as `true`, only when the link was given `replace` — see\n * `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */\n replace?: boolean;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface TextLinkProps\n extends Omit<ComponentPropsWithoutRef<\"a\">, \"href\" | \"children\" | \"aria-current\" | \"onClick\" | \"onKeyDown\"> {\n href: string;\n children: ReactNode;\n /** Default `brand`. */\n tone?: TextLinkTone;\n /** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */\n underline?: TextLinkUnderline;\n /**\n * Navigate by replacing the current history entry, so Back does not return to this\n * URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed\n * to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a\n * plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes\n * `location.replace(href)`. Ignored on an `external` link, which opens a new tab.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`: the browser loads the whole document instead of\n * the router swapping the view — keksdose F1, a route served outside the SPA (a file\n * download, a server-rendered page, a logout that must drop every in-memory cache).\n * Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying\n * \"not the router\" for this one link.\n */\n reloadDocument?: boolean;\n /**\n * The link leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`\n * (never the router's), an external-link mark after the text (mirrored in RTL), and\n * \"opens in a new tab\" (`common.opensInNewTab`) read after it — the mark says it\n * only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).\n */\n external?: boolean;\n /**\n * Keep the click (and the Enter that activates the link) from reaching an ancestor —\n * a link in a DataTable row with `onRowClick` goes to its own target instead of\n * running the row's action. kastlan's `CellLink` did this by hand.\n */\n stopPropagation?: boolean;\n /** Mark the link as the current one of a set — `true` is `aria-current=\"page\"`, the\n * legal footer's link to the page you are on. Drawn in semibold. */\n current?: boolean | TextLinkCurrent;\n /** A Lucide icon before the text, at the text's size — kastlan's \"← Back\" link. */\n icon?: LucideIcon;\n /** Your router's link for an in-app `href`. Default: the `<UiKitProvider\n * linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */\n renderLink?: (props: TextLinkRenderProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n ref?: Ref<HTMLAnchorElement>;\n}\n\nconst TONE: Record<TextLinkTone, string> = {\n brand: \"text-brand hover:text-[var(--brand-hover)]\",\n muted: \"text-muted hover:text-primary\",\n danger: \"text-danger hover:text-[var(--danger-hover)]\",\n inherit: \"\",\n primary: \"text-primary hover:decoration-2\",\n secondary: \"text-secondary hover:text-primary\",\n warning: \"text-warning hover:decoration-2\",\n};\n\n/** The tones whose colour is their surroundings' — underlined by default; see\n * {@link TextLinkTone}. */\nconst UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>([\"primary\", \"secondary\", \"warning\"]);\n\nconst UNDERLINE: Record<TextLinkUnderline, string> = {\n hover: \"no-underline hover:underline\",\n always: \"underline\",\n none: \"no-underline\",\n};\n\n/**\n * The one inline link: a real `<a href>` (middle click, \"open in new tab\", link\n * semantics) through the app's router for an in-app path, in one of four tones.\n *\n * kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,\n * hover underline, one stopping the row click), the legal pages' back link and footer,\n * the feedback page's URL links, the calculator's external sources with a hand-placed\n * ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)\n * used a raw `<a>` to an in-app route, which reloads the whole app. Here the router\n * link comes from `<UiKitProvider linkComponent>`, set once.\n *\n * Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit\n * on the text's baseline at the text's size, so they scale with whatever type the link\n * is set in. The focus ring is an outline, which follows every line of a wrapped link.\n */\nexport function TextLink({\n href,\n children,\n tone = \"brand\",\n underline: underlineProp,\n external = false,\n replace,\n reloadDocument = false,\n stopPropagation = false,\n current,\n icon: Icon,\n renderLink,\n className,\n onClick,\n onKeyDown,\n ref,\n ...rest\n}: TextLinkProps) {\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const kitLink = useKitLink();\n const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? \"always\" : \"hover\");\n const ariaCurrent: TextLinkCurrent | undefined =\n current === true ? \"page\" : current === false ? undefined : current;\n\n const look = cn(\n \"rounded-sm underline-offset-2 transition-colors\",\n \"focus-visible:outline-focus focus-visible:outline-offset-2 focus-visible:outline-brand\",\n TONE[tone],\n UNDERLINE[underline],\n ariaCurrent && \"font-semibold\",\n className,\n );\n\n const body = (\n <>\n {Icon && <Icon aria-hidden className=\"me-1 inline size-[1em] shrink-0 align-[-0.125em]\" />}\n {children}\n {external && (\n <>\n <ExternalLink aria-hidden className=\"ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100\" />\n {\" \"}\n <span className=\"sr-only\">({common.opensInNewTab})</span>\n </>\n )}\n </>\n );\n\n const handleClick: TextLinkRenderProps[\"onClick\"] = stopPropagation\n ? (event) => {\n event.stopPropagation();\n onClick?.(event);\n }\n : onClick;\n const props: TextLinkRenderProps = {\n ...(rest as Partial<TextLinkRenderProps>),\n ref,\n href,\n className: look,\n \"aria-current\": ariaCurrent,\n onClick: handleClick,\n onKeyDown: stopPropagation\n ? (event) => {\n if (event.key === \"Enter\") event.stopPropagation();\n onKeyDown?.(event);\n }\n : onKeyDown,\n children: body,\n };\n\n const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);\n if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;\n // A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.\n const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);\n const { children: content, ...anchor } = props;\n return (\n <a\n {...anchor}\n href={href}\n onClick={plainClick}\n {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : null)}\n >\n {content}\n </a>\n );\n}\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedTextLink({\n render,\n ...props\n}: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/text-link.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { ExternalLink } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent } from \"../i18n/kit-labels\";\nimport { documentNavigation } from \"../lib/document-navigation\";\n\n/* ── Choosing what draws a link ──────────────────────────────────────────── */\n\n/**\n * An `href` that leaves the app: a scheme (`https:`, `mailto:`, `tel:`) or a\n * protocol-relative `//host`. A router's link is for paths inside the app, so the\n * provider's `linkComponent` is never handed one of these — they stay a plain `<a>`.\n *\n * NOT external, and so handed to the router link: a path (`/x`, `x`, `?q=1`) and the\n * hash-router form `#/x` — that IS a route in a `HashRouter` app, which maps it to\n * `to=\"/x\"`. A plain in-page anchor (`#section`) is not external either, but it is not\n * a route: see {@link isInPageAnchor}.\n */\nexport function isExternalHref(href: string): boolean {\n return /^(?:[a-z][a-z\\d+.-]*:|\\/\\/)/i.test(href);\n}\n\n/**\n * An `href` that only scrolls the current page: `#section` (or a bare `#`). The\n * hash-router form `#/x` is a ROUTE, not an anchor, and is not one of these. A router\n * link must not get an anchor — under a `HashRouter` `#section` would navigate to the\n * path `/section` — so every kit link draws it as a plain `<a>`.\n */\nexport function isInPageAnchor(href: string): boolean {\n return href.startsWith(\"#\") && !href.startsWith(\"#/\");\n}\n\n/**\n * Whether the provider's `linkComponent` (or a kit component's router fallback) draws\n * `href`: an in-app path, `#/x` included — not an external `href`\n * ({@link isExternalHref}) and not an in-page `#anchor` ({@link isInPageAnchor}).\n */\nexport function isRoutableHref(href: string): boolean {\n return !isExternalHref(href) && !isInPageAnchor(href);\n}\n\n/**\n * What draws a kit link to `href`: the component's own `renderLink` when it was given\n * one (it always wins — the caller asked for it by name), else the\n * `<UiKitProvider linkComponent>` for a routable `href` ({@link isRoutableHref}), else\n * `undefined` — a plain `<a>`. The one rule every kit link follows: `https:` /\n * `mailto:` / `//host` and `#section` stay plain anchors; `/x`, `x` and a hash-router\n * `#/x` go to the router link. Every kit link props type is a subset of the anchor\n * attributes `KitLinkProps` describes, which is what makes the cast sound.\n * @internal Shared by the kit's link-bearing components; not part of the barrel.\n */\nexport function pickLinkRenderer<P>(\n own: ((props: P) => ReactElement) | undefined,\n kitLink: KitLinkComponent | undefined,\n href: string | undefined,\n): ((props: P) => ReactElement) | undefined {\n if (own) return own;\n if (!kitLink || href === undefined || !isRoutableHref(href)) return undefined;\n return kitLink as unknown as (props: P) => ReactElement;\n}\n\n/** The plain primary click a page navigates on — not a middle click, not ⌘ / Ctrl /\n * Shift / Alt (new tab, new window, download), and not one a handler already took. */\nfunction isPlainNavigationClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n !event.defaultPrevented &&\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey\n );\n}\n\n/**\n * The `onClick` of a PLAIN `<a>` whose kit link was asked to `replace` (keksdose F1).\n *\n * A router link replaces the history entry itself (it is handed `replace`, see\n * {@link routerLinkNavigation}); a plain `<a>` has no attribute that says so, and the\n * browser's own navigation always pushes. So a plain click is taken over — the caller's\n * `onClick` runs first and may still `preventDefault` — and becomes\n * `location.replace(href)`: the same document load, with Back skipping the one-shot URL.\n * That is the case of `replace` together with `reloadDocument`, or an app with no\n * `linkComponent`. A modified click (new tab) and a `target` other than `_self` are left\n * to the browser: they do not navigate THIS history. Without `replace`, `onClick` comes\n * back untouched.\n * @internal\n */\nexport function replacingClick(\n onClick: ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined,\n href: string,\n replace: boolean | undefined,\n target?: string,\n): ((event: MouseEvent<HTMLAnchorElement>) => void) | undefined {\n if (!replace || (target !== undefined && target !== \"\" && target !== \"_self\")) return onClick;\n return (event) => {\n onClick?.(event);\n if (!isPlainNavigationClick(event)) return;\n event.preventDefault();\n documentNavigation.replace(href);\n };\n}\n\n/**\n * What a ROUTER link is handed for `replace`: `{ replace: true }` when asked, else\n * nothing at all — so a provider link that spreads its props onto a DOM `<a>` never\n * receives an unknown attribute from a link that did not ask (see `KitLinkProps.replace`).\n * @internal\n */\nexport function routerLinkNavigation(replace: boolean | undefined): { replace?: true } {\n return replace ? { replace: true } : {};\n}\n\n/**\n * Draws `props` through `render` as a component of its own, so a router link's hooks\n * belong to it and not to the kit component calling it — for the kit components that\n * call their own `renderLink` inline and reach the provider's link through here.\n * @internal\n */\nexport function RenderedKitLink<P>({ render, props }: { render: (props: P) => ReactElement; props: P }) {\n return render(props);\n}\n\n/* ── TextLink ─────────────────────────────────────────────────────────────── */\n\n/**\n * `brand` (default) for a link in running text, `muted` for footer and meta links\n * that should not compete with the content, `danger` for a destructive one (\"Leave\n * group\"), `inherit` for a link that takes its surroundings' colour.\n *\n * `primary` and `secondary` are the body and the secondary text colour, UNDERLINED\n * (keksdose F3): a link that must read as part of the text around it — a name in a\n * list row, a reference in a card's meta line — where `brand` repaints it in the accent\n * and `muted` is a step too light to read as content. `secondary` darkens to the body\n * colour under the pointer, as `muted` does; `primary` has nowhere darker to go, so its\n * underline thickens instead.\n *\n * `warning` is the kit's `--warning` text colour, underlined (kastlan 46): a link inside\n * a `<Tone tone=\"warning\">` sentence or a warning banner, which `brand` would paint in a\n * second hue and `inherit` would leave indistinguishable from the sentence. Like\n * `primary`, it keeps its colour on hover and thickens the underline.\n *\n * The three colours that sit IN the text default to `underline=\"always\"`: with the\n * colour of their surroundings, the underline is the only thing that says \"link\"\n * (WCAG 1.4.1). An explicit `underline` still wins.\n */\nexport type TextLinkTone = \"brand\" | \"muted\" | \"danger\" | \"inherit\" | \"primary\" | \"secondary\" | \"warning\";\n\n/** `hover` (the default for `brand`, `muted`, `danger`, `inherit`): underlined under\n * the pointer — the idiom all three apps draw. `always` (the default for `primary`,\n * `secondary`, `warning`) for a link inside prose, where colour alone must not be the\n * only mark (WCAG 1.4.1). `none` where the context already says \"link\" (a footer nav). */\nexport type TextLinkUnderline = \"hover\" | \"always\" | \"none\";\n\n/** The `aria-current` token. `true` on {@link TextLinkProps.current} means `page`. */\nexport type TextLinkCurrent = \"page\" | \"step\" | \"location\" | \"date\" | \"time\" | \"true\";\n\n/** What {@link TextLinkProps.renderLink} (and the provider's `linkComponent`) is\n * handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface TextLinkRenderProps {\n href: string;\n /** The link's look — keep it, or the tone, underline and focus ring go. */\n className: string;\n children: ReactNode;\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: TextLinkCurrent;\n id?: string;\n title?: string;\n /** Present, as `true`, only when the link was given `replace` — see\n * `KitLinkProps.replace`. Map it to your router's replace; never onto a DOM `<a>`. */\n replace?: boolean;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nexport interface TextLinkProps\n extends Omit<ComponentPropsWithoutRef<\"a\">, \"href\" | \"children\" | \"aria-current\" | \"onClick\" | \"onKeyDown\"> {\n href: string;\n children: ReactNode;\n /** Default `brand`. */\n tone?: TextLinkTone;\n /** Default `always` for `primary`, `secondary` and `warning`, else `hover`. */\n underline?: TextLinkUnderline;\n /**\n * Navigate by replacing the current history entry, so Back does not return to this\n * URL — keksdose F1, a one-shot URL (a confirmation landing, a `?done=1` hop). Handed\n * to the router link as `replace` (see `KitLinkProps.replace` for mapping it); on a\n * plain `<a>` (no `linkComponent`, or `reloadDocument`) a plain click becomes\n * `location.replace(href)`. Ignored on an `external` link, which opens a new tab.\n */\n replace?: boolean;\n /**\n * A plain `<a>` for an in-app `href`: the browser loads the whole document instead of\n * the router swapping the view — keksdose F1, a route served outside the SPA (a file\n * download, a server-rendered page, a logout that must drop every in-memory cache).\n * Wins over `renderLink` and the provider's `linkComponent`: it is the caller saying\n * \"not the router\" for this one link.\n */\n reloadDocument?: boolean;\n /**\n * The link leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`\n * (never the router's), an external-link mark after the text (mirrored in RTL), and\n * \"opens in a new tab\" (`common.opensInNewTab`) read after it — the mark says it\n * only to the eye. kastlan's BWO / BFS source links (rent-increase-calculator-page).\n */\n external?: boolean;\n /**\n * Keep the click (and the Enter that activates the link) from reaching an ancestor —\n * a link in a DataTable row with `onRowClick` goes to its own target instead of\n * running the row's action. kastlan's `CellLink` did this by hand.\n */\n stopPropagation?: boolean;\n /** Mark the link as the current one of a set — `true` is `aria-current=\"page\"`, the\n * legal footer's link to the page you are on. Drawn in semibold. */\n current?: boolean | TextLinkCurrent;\n /** A Lucide icon before the text, at the text's size — kastlan's \"← Back\" link. */\n icon?: LucideIcon;\n /** Your router's link for an in-app `href`. Default: the `<UiKitProvider\n * linkComponent>`, then a plain `<a>`. Ignored on an `external` link. */\n renderLink?: (props: TextLinkRenderProps) => ReactElement;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onKeyDown?: (event: KeyboardEvent<HTMLAnchorElement>) => void;\n ref?: Ref<HTMLAnchorElement>;\n}\n\nconst TONE: Record<TextLinkTone, string> = {\n // Under More contrast the brand as text, `--brand-muted`, at rest and hovered (0.36,\n // colour-roles §13.3 decision 8): `--brand` is a mark's colour, solved to 3:1.\n brand: \"text-brand hover:text-[var(--brand-hover)] contrast:text-brand-muted contrast:hover:text-brand-muted\",\n muted: \"text-muted hover:text-primary\",\n danger: \"text-danger hover:text-[var(--danger-hover)]\",\n inherit: \"\",\n primary: \"text-primary hover:decoration-2\",\n secondary: \"text-secondary hover:text-primary\",\n warning: \"text-warning hover:decoration-2\",\n};\n\n/** The tones whose colour is their surroundings' — underlined by default; see\n * {@link TextLinkTone}. */\nconst UNDERLINED_BY_DEFAULT = new Set<TextLinkTone>([\"primary\", \"secondary\", \"warning\"]);\n\nconst UNDERLINE: Record<TextLinkUnderline, string> = {\n hover: \"no-underline hover:underline\",\n always: \"underline\",\n none: \"no-underline\",\n};\n\n/**\n * The one inline link: a real `<a href>` (middle click, \"open in new tab\", link\n * semantics) through the app's router for an in-app path, in one of four tones.\n *\n * kastlan draws this link a dozen ways by hand — `EntityLink` and `CellLink` (text-primary,\n * hover underline, one stopping the row click), the legal pages' back link and footer,\n * the feedback page's URL links, the calculator's external sources with a hand-placed\n * ↗ and no word for a screen reader — and the lease wizard's unit step (lease-unit-step)\n * used a raw `<a>` to an in-app route, which reloads the whole app. Here the router\n * link comes from `<UiKitProvider linkComponent>`, set once.\n *\n * Inline, not `inline-flex`: a link inside a sentence must wrap with it. The icons sit\n * on the text's baseline at the text's size, so they scale with whatever type the link\n * is set in. The focus ring is an outline, which follows every line of a wrapped link.\n */\nexport function TextLink({\n href,\n children,\n tone = \"brand\",\n underline: underlineProp,\n external = false,\n replace,\n reloadDocument = false,\n stopPropagation = false,\n current,\n icon: Icon,\n renderLink,\n className,\n onClick,\n onKeyDown,\n ref,\n ...rest\n}: TextLinkProps) {\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const kitLink = useKitLink();\n const underline = underlineProp ?? (UNDERLINED_BY_DEFAULT.has(tone) ? \"always\" : \"hover\");\n const ariaCurrent: TextLinkCurrent | undefined =\n current === true ? \"page\" : current === false ? undefined : current;\n\n const look = cn(\n \"rounded-sm underline-offset-2 transition-colors\",\n \"focus-visible:outline-focus focus-visible:outline-offset-2 focus-visible:outline-brand\",\n TONE[tone],\n UNDERLINE[underline],\n ariaCurrent && \"font-semibold\",\n className,\n );\n\n const body = (\n <>\n {Icon && <Icon aria-hidden className=\"me-1 inline size-[1em] shrink-0 align-[-0.125em]\" />}\n {children}\n {external && (\n <>\n <ExternalLink aria-hidden className=\"ms-1 inline size-[0.85em] align-[-0.1em] rtl:-scale-x-100\" />\n {\" \"}\n <span className=\"sr-only\">({common.opensInNewTab})</span>\n </>\n )}\n </>\n );\n\n const handleClick: TextLinkRenderProps[\"onClick\"] = stopPropagation\n ? (event) => {\n event.stopPropagation();\n onClick?.(event);\n }\n : onClick;\n const props: TextLinkRenderProps = {\n ...(rest as Partial<TextLinkRenderProps>),\n ref,\n href,\n className: look,\n \"aria-current\": ariaCurrent,\n onClick: handleClick,\n onKeyDown: stopPropagation\n ? (event) => {\n if (event.key === \"Enter\") event.stopPropagation();\n onKeyDown?.(event);\n }\n : onKeyDown,\n children: body,\n };\n\n const render = external || reloadDocument ? undefined : pickLinkRenderer(renderLink, kitLink, href);\n if (render) return <RenderedTextLink render={render} {...props} {...routerLinkNavigation(replace)} />;\n // A plain `<a>` never gets `replace` as an attribute; asked for, it takes the click.\n const plainClick = external ? handleClick : replacingClick(handleClick, href, replace, rest.target);\n const { children: content, ...anchor } = props;\n return (\n <a\n {...anchor}\n href={href}\n onClick={plainClick}\n {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : null)}\n >\n {content}\n </a>\n );\n}\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedTextLink({\n render,\n ...props\n}: TextLinkRenderProps & { render: (props: TextLinkRenderProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode, Ref, TextareaHTMLAttributes } from "react";
|
|
2
|
+
import { type FieldHelpProps } from "./field-parts.js";
|
|
2
3
|
import { type CharacterCountLabels } from "./field-base.js";
|
|
3
|
-
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement
|
|
4
|
+
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement>, FieldHelpProps {
|
|
4
5
|
label?: ReactNode;
|
|
5
6
|
/** See {@link Input}'s `invalid`. */
|
|
6
7
|
invalid?: boolean;
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useLayoutEffect, useRef } from "react";
|
|
4
4
|
import { cn } from "../lib/cn.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
EndHintRow,
|
|
7
|
+
FieldCaption,
|
|
8
|
+
mergeDescribedBy,
|
|
9
|
+
useFieldError,
|
|
10
|
+
useFieldHint
|
|
11
|
+
} from "./field-parts.js";
|
|
6
12
|
import {
|
|
7
13
|
CharacterCount,
|
|
8
14
|
FIELD_BASE,
|
|
@@ -86,12 +92,12 @@ function TextareaLabelStrip() {
|
|
|
86
92
|
return /* @__PURE__ */ jsx("span", { ref, "aria-hidden": true, "data-slot": "label-strip", className: TEXTAREA_LABEL_STRIP });
|
|
87
93
|
}
|
|
88
94
|
function Textarea(props) {
|
|
89
|
-
const { ref, className, label, id, placeholder, invalid, error, hint, showCount, countLabels, ...rest } = props;
|
|
95
|
+
const { ref, className, label, id, placeholder, invalid, error, hint, help, showCount, countLabels, ...rest } = props;
|
|
90
96
|
const reserve = "error" in props || "hint" in props || "showCount" in props;
|
|
91
97
|
const generated = useId();
|
|
92
98
|
const fieldId = id ?? generated;
|
|
93
99
|
const countId = useId();
|
|
94
|
-
const hintParts = useFieldHint(hint, rest["aria-describedby"]);
|
|
100
|
+
const hintParts = useFieldHint(hint, rest["aria-describedby"], { help, name: label ?? rest["aria-label"] });
|
|
95
101
|
const maxLength = rest.maxLength;
|
|
96
102
|
const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
|
|
97
103
|
const length = useTypedLength(rest.value, rest.defaultValue);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/textarea.tsx"],"sourcesContent":["import { useId, useLayoutEffect, useRef } from \"react\";\nimport type { ChangeEvent, ReactNode, Ref, TextareaHTMLAttributes } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { EndHintRow, FieldCaption, mergeDescribedBy, useFieldError, useFieldHint } from \"./field-parts\";\nimport {\n CharacterCount,\n FIELD_BASE,\n FIELD_INVALID,\n FieldGroup,\n FloatingField,\n FLOATING_INPUT_CLASS,\n FLOATING_PLACEHOLDER_ON_FOCUS,\n fieldBelow,\n hasPlaceholderText,\n useCharacterCountLabels,\n useTypedLength,\n type CharacterCountLabels,\n} from \"./field-base\";\n\n/**\n * The lower edge of {@link TEXTAREA_LABEL_STRIP} (0.25, Kurvenschmiede).\n *\n * The 0.24 strip ends on a hard line, and a line of text scrolled most of the way under\n * it left its lower edge showing below that line: in a pasted table, a row of comma\n * tails between the strip and the first whole line (`0,5;1,9` cut above its baseline is\n * `, ,`). This layer, hung under the strip, closes that gap. What it draws depends on\n * how much of the line on its way out is still below the strip ({@link labelEdge}):\n *\n * - **Half a line or less** — its descenders, the feet of its letters: covered, solid,\n * down to exactly where that line's box ends. The strip's bottom snaps to the line\n * box, so the row of tails is gone and the next line, which starts there, is not\n * touched.\n * - **More than half** — a line you can still read: a soft edge, the strip's surface\n * fading into it over {@link LABEL_EDGE_SOFT_PX}, where 0.24 cut it hard. Never taller\n * than the part of that line already gone, so it grows from nothing as a line starts\n * to move and never reaches the next one.\n * - **Nothing to cover** — at rest, or a line boundary right on the strip's edge:\n * nothing drawn. An unscrolled first line is never covered (at rest the strip covers\n * the top padding and nothing else, as in 0.24), and neither is any whole line.\n *\n * Why not one of the two alone. A plain fade under the strip, as tall as the tails,\n * dims the top of the first WHOLE line whenever it sits right under the strip (its\n * capitals start about 4px into a 20px line), and only half-hides the tails, which sit\n * in its fading part. A plain snap to the line box would hide a whole line the moment\n * one starts to scroll. The half-line threshold keeps what is shown legible: a line is\n * either hidden or shown with most of its letters, never as a row of stray marks.\n *\n * Its height and solid part are `--label-strip-edge` / `--label-strip-edge-solid`,\n * written by {@link TextareaLabelStrip} from the textarea's `scrollTop` and line height\n * on every scroll; unset (no layout, no script yet) the height is 0 and nothing shows.\n * A pseudo-element of the strip, so it inherits everything the strip already gets\n * right: shown only while the label is floated, the same horizontal insets (stopped\n * short of a classic scrollbar, on either side — RTL included), painted under the\n * label. Its colour is INHERITED from the strip (`bg-inherit`): `--bg-surface`, and the\n * disabled / `[readonly]` `--bg-surface-2`, in both themes, with no second copy of\n * those selectors to keep in step. The soft part is a mask over that colour (alpha\n * only — the `#000` is the mask's opacity, not a colour on screen).\n */\nconst TEXTAREA_LABEL_EDGE = cn(\n \"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-[var(--label-strip-edge,0px)] after:content-['']\",\n \"after:bg-inherit after:[mask-image:linear-gradient(#000_var(--label-strip-edge-solid,0px),transparent)]\",\n);\n\n/** The soft edge over a line still more than half in view, in px: \"a few pixels\" —\n * enough to take the cut off its letters, little enough to leave them legible. */\nconst LABEL_EDGE_SOFT_PX = 3;\n\n/**\n * {@link TEXTAREA_LABEL_EDGE}'s height and solid part, in px, for a field scrolled\n * `scrollTop` with lines `lineHeight` tall.\n *\n * The strip covers the top padding exactly, so line boxes meet its lower edge whenever\n * `scrollTop` is a whole number of lines; `gone` is how far the line now on its way out\n * has passed under it, `left` how much of it is still below. A line height that is not\n * a length (`normal`) gives no line boxes to snap to: only the soft edge then.\n */\nfunction labelEdge(scrollTop: number, lineHeight: number): { height: number; solid: number } {\n if (!(scrollTop > 0)) return { height: 0, solid: 0 };\n if (!(lineHeight > 0)) return { height: Math.min(scrollTop, LABEL_EDGE_SOFT_PX), solid: 0 };\n const gone = scrollTop % lineHeight;\n const left = gone === 0 ? 0 : lineHeight - gone;\n if (left > 0 && left <= lineHeight / 2) return { height: left, solid: left };\n return { height: Math.min(gone, LABEL_EDGE_SOFT_PX), solid: 0 };\n}\n\n/**\n * The backdrop of a labelled {@link Textarea}'s label strip (Kurvenschmiede, 0.24).\n *\n * The report: paste a long table into ColumnMapper, the field scrolls, and the floated\n * \"Paste a table\" / \"Tabelle einfügen\" sits ON the first visible line of text — on\n * desktop and on a phone. Not a ColumnMapper bug: every labelled textarea that scrolls\n * did it. An `<input>` has one line that never moves, so the strip FIELD_FLOATING_PAD\n * leaves for the label is always empty there; a textarea's top padding is part of its\n * SCROLLING area, and once the content scrolls the lines travel up through the strip\n * and under the label.\n *\n * The fix makes that padding stay put: this layer is a later sibling of the textarea\n * (so `peer-*` reaches it, and as a positioned box it paints above the textarea's\n * text), the label paints above it in turn, and it covers exactly the top padding —\n * `top-px` / `inset-x-px` inside the 1px border, `h-4` = `pt-4`. At rest it covers\n * padding and nothing else, so no pixel of an unscrolled line is ever hidden; scrolled\n * text slides out of sight under it instead of through the label.\n *\n * Why a layer and not \"start the scrolling area below the strip\" (a wrapper that draws\n * the border and background, the textarea transparent inside it): the box would stop\n * being the textarea. Its focus outline, its brand border on focus, the invalid ring,\n * the disabled and read-only greys and every caller's `[&_textarea]:…` would all have\n * to be rebuilt on the wrapper with `focus-within:` / `has-[…]:`, and a caller's\n * `style={{ height }}` would size the scroller rather than the field. The layer leaves\n * all of that exactly where it was.\n *\n * What it has to match, it matches from the same tokens the field uses: the surface is\n * FIELD_BASE's `--bg-surface`, and `--bg-surface-2` for `disabled` and `[readonly]` —\n * on the same ATTRIBUTE selector FIELD_BASE uses, for the reason given there. Focus and\n * `invalid` change only the border and the ring, which are outside the layer, so they\n * need nothing. Both themes come with the variables. The inner corners are the field's\n * radius (`rounded-md`, 0.375rem) less the 1px border, so the rounded border is never\n * cut into — `calc(0.375rem - 1px)` rather than `var(--radius-md)`, which Tailwind emits\n * only where a utility uses it and the kit's token check refuses (token-vars-declared).\n * Since 0.32 in rem (§3.2): the field's own corner grows with the text, so a fixed 5px\n * left the strip's corner square of the border's at 125 %.\n *\n * Hidden (`display: none`) until the label floats: an empty, unfocused field has\n * nothing that could scroll, and it renders exactly as it did before 0.24. Pointer\n * events pass through, so a click on the strip still lands in the field.\n *\n * The one thing CSS cannot know is a classic scrollbar's width, and a strip across the\n * whole inner width would hide the scrollbar's top arrow — see {@link TextareaLabelStrip}.\n *\n * Its lower edge, where a line scrolled half under it used to leave its tails showing,\n * is {@link TEXTAREA_LABEL_EDGE} (0.25).\n */\nconst TEXTAREA_LABEL_STRIP = cn(\n \"pointer-events-none absolute inset-x-px top-px hidden h-4 rounded-t-[calc(0.375rem-1px)] bg-surface\",\n // Never at Large (0.33, §10.17): the label stands above the field, so there is no\n // strip to keep scrolled text out of, and a layer here would hide the first line.\n \"not-large:peer-focus:block not-large:peer-[:not(:placeholder-shown)]:block\",\n \"peer-disabled:bg-surface-2 peer-[[readonly]]:bg-surface-2\",\n TEXTAREA_LABEL_EDGE,\n);\n\n/**\n * {@link TEXTAREA_LABEL_STRIP}, stopped short of a classic scrollbar.\n *\n * A Windows / Linux desktop draws a textarea's vertical scrollbar INSIDE the border, in\n * a gutter it takes from the content box (15px in Chromium); a strip across the full\n * inner width would sit on top of the scrollbar's up arrow. So the gutter is measured —\n * border box less client width less the borders — and the strip ends where it begins.\n * On whichever side it is: `clientLeft` includes a gutter on the LEFT, which is where\n * Chromium and Firefox put it in a right-to-left field. The side is applied as an\n * inline-start / inline-end inset, so when `dir` flips afterwards and the scrollbar\n * follows it to the other edge, the strip does too without a re-measure. That corner\n * also loses its rounding, since it no longer meets the border.\n *\n * Overlay scrollbars (macOS, phones) take no gutter: the measurement is zero and the\n * strip spans the field. A ResizeObserver on the textarea re-measures when the\n * scrollbar comes or goes (it takes the content box with it) and when the field is\n * resized by hand. Not laid out — hidden in a closed panel, or jsdom — the CSS insets\n * stand until it is.\n *\n * It also writes the strip's lower edge (0.25, {@link TEXTAREA_LABEL_EDGE}) from the\n * textarea's scroll position: a passive `scroll` listener, plus the same observer, since\n * a resize can move the scroll without the user scrolling.\n */\nfunction TextareaLabelStrip() {\n const ref = useRef<HTMLSpanElement>(null);\n useLayoutEffect(() => {\n const strip = ref.current;\n const field = strip?.previousElementSibling;\n if (!strip || !(field instanceof HTMLTextAreaElement)) return;\n const fit = () => {\n if (field.offsetWidth === 0) return;\n const style = getComputedStyle(field);\n const borderLeft = parseFloat(style.borderLeftWidth) || 0;\n const borderRight = parseFloat(style.borderRightWidth) || 0;\n const gutter = field.offsetWidth - field.clientWidth - borderLeft - borderRight;\n // offsetWidth and clientWidth are whole pixels, so a field with no scrollbar can\n // read ±1 here; no scrollbar is that thin.\n const bar = gutter > 1.5;\n const onLeft = field.clientLeft - borderLeft > 1.5;\n // Kept as a LOGICAL side: a `dir` flipped later (a language switch) moves the\n // scrollbar across without resizing anything, so nothing would re-measure —\n // but it stays at the inline end, and so does an inline-end inset.\n const rtl = style.direction === \"rtl\";\n const atEnd = onLeft === rtl;\n const border = (end: boolean) => (end === rtl ? borderLeft : borderRight);\n strip.style.insetInlineEnd = bar && atEnd ? `${border(true) + gutter}px` : \"\";\n strip.style.insetInlineStart = bar && !atEnd ? `${border(false) + gutter}px` : \"\";\n strip.style.borderStartEndRadius = bar && atEnd ? \"0\" : \"\";\n strip.style.borderStartStartRadius = bar && !atEnd ? \"0\" : \"\";\n };\n // The edge under the strip follows the scroll — see TEXTAREA_LABEL_EDGE. The line\n // height is read each time, since a caller's class can change the type. Nothing to\n // draw is no property at all (the CSS default is a 0px edge), so a field at rest\n // carries exactly the strip it did in 0.24.\n const follow = () => {\n const edge = labelEdge(field.scrollTop, parseFloat(getComputedStyle(field).lineHeight));\n if (edge.height > 0) {\n strip.style.setProperty(\"--label-strip-edge\", `${edge.height}px`);\n strip.style.setProperty(\"--label-strip-edge-solid\", `${edge.solid}px`);\n } else {\n strip.style.removeProperty(\"--label-strip-edge\");\n strip.style.removeProperty(\"--label-strip-edge-solid\");\n }\n };\n fit();\n follow();\n field.addEventListener(\"scroll\", follow, { passive: true });\n if (typeof ResizeObserver === \"undefined\") return () => field.removeEventListener(\"scroll\", follow);\n // A resize can move the scroll too (a taller field clamps it).\n const observer = new ResizeObserver(() => {\n fit();\n follow();\n });\n observer.observe(field);\n return () => {\n field.removeEventListener(\"scroll\", follow);\n observer.disconnect();\n };\n }, []);\n return <span ref={ref} aria-hidden data-slot=\"label-strip\" className={TEXTAREA_LABEL_STRIP} />;\n}\n\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n label?: ReactNode;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`. */\n error?: ReactNode;\n /** See {@link Input}'s `hint` (keksdose K4): text is a caption under the field, a\n * {@link FieldHint} rides the label line (or, unlabelled, the end edge). */\n hint?: ReactNode;\n /** See {@link Input}'s `showCount` (keksdose K11): a live \"12/80\" under the field,\n * with `maxLength`, announced only near and at the limit. */\n showCount?: boolean;\n /** The counter's words. See {@link CharacterCountLabels}. */\n countLabels?: Partial<CharacterCountLabels>;\n /** The `<textarea>`. A React 19 ref prop since 0.34 — see {@link InputProps.ref}. */\n ref?: Ref<HTMLTextAreaElement>;\n}\n\nexport function Textarea(props: TextareaProps) {\n const { ref, className, label, id, placeholder, invalid, error, hint, showCount, countLabels, ...rest } = props;\n // See Input's `reserve`.\n const reserve = \"error\" in props || \"hint\" in props || \"showCount\" in props;\n const generated = useId();\n const fieldId = id ?? generated;\n const countId = useId();\n const hintParts = useFieldHint(hint, rest[\"aria-describedby\"]);\n const maxLength = rest.maxLength;\n const counting = Boolean(showCount) && typeof maxLength === \"number\" && maxLength > 0;\n const length = useTypedLength(rest.value, rest.defaultValue);\n const countText = useCharacterCountLabels(countLabels);\n const { isInvalid, describedBy, errorEl } = useFieldError(\n error,\n invalid,\n mergeDescribedBy(hintParts.describedBy, counting && countId),\n rest[\"aria-invalid\"],\n );\n const counter = counting ? (\n <CharacterCount\n id={countId}\n used={length.used}\n max={maxLength}\n labels={countText}\n caption={<FieldCaption parts={hintParts} className=\"mt-0\" />}\n />\n ) : null;\n const below = fieldBelow(hintParts, counter, errorEl);\n const onChange = counting\n ? (e: ChangeEvent<HTMLTextAreaElement>) => {\n length.track(e.target.value);\n rest.onChange?.(e);\n }\n : rest.onChange;\n if (label === undefined) {\n const endHint = hintParts.labelHint;\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n <EndHintRow hint={endHint} className={className}>\n <textarea\n ref={ref}\n id={id}\n placeholder={placeholder}\n {...rest}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onChange={onChange}\n className={cn(FIELD_BASE, endHint === undefined && className, isInvalid && FIELD_INVALID)}\n />\n </EndHintRow>\n </FieldGroup>\n );\n }\n const ownPlaceholder = hasPlaceholderText(placeholder);\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n <FloatingField className={className} htmlFor={fieldId} label={label} hint={hintParts.labelHint}>\n <textarea\n ref={ref}\n id={fieldId}\n // The float trick's single space, unless the caller has a placeholder of its\n // own — then that, shown on focus once the label is out of its way (kastlan\n // 8, see FLOATING_PLACEHOLDER_ON_FOCUS).\n placeholder={ownPlaceholder ? placeholder : \" \"}\n {...rest}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onChange={onChange}\n className={cn(\n FLOATING_INPUT_CLASS,\n ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS,\n isInvalid && FIELD_INVALID,\n )}\n />\n {/* Between the textarea and the label, on purpose: after the textarea so it\n is its `peer` and paints over its text, before the label so the label\n paints over it. See TEXTAREA_LABEL_STRIP. */}\n <TextareaLabelStrip />\n </FloatingField>\n </FieldGroup>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/textarea.tsx"],"sourcesContent":["import { useId, useLayoutEffect, useRef } from \"react\";\nimport type { ChangeEvent, ReactNode, Ref, TextareaHTMLAttributes } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport {\n EndHintRow,\n FieldCaption,\n mergeDescribedBy,\n useFieldError,\n useFieldHint,\n type FieldHelpProps,\n} from \"./field-parts\";\nimport {\n CharacterCount,\n FIELD_BASE,\n FIELD_INVALID,\n FieldGroup,\n FloatingField,\n FLOATING_INPUT_CLASS,\n FLOATING_PLACEHOLDER_ON_FOCUS,\n fieldBelow,\n hasPlaceholderText,\n useCharacterCountLabels,\n useTypedLength,\n type CharacterCountLabels,\n} from \"./field-base\";\n\n/**\n * The lower edge of {@link TEXTAREA_LABEL_STRIP} (0.25, Kurvenschmiede).\n *\n * The 0.24 strip ends on a hard line, and a line of text scrolled most of the way under\n * it left its lower edge showing below that line: in a pasted table, a row of comma\n * tails between the strip and the first whole line (`0,5;1,9` cut above its baseline is\n * `, ,`). This layer, hung under the strip, closes that gap. What it draws depends on\n * how much of the line on its way out is still below the strip ({@link labelEdge}):\n *\n * - **Half a line or less** — its descenders, the feet of its letters: covered, solid,\n * down to exactly where that line's box ends. The strip's bottom snaps to the line\n * box, so the row of tails is gone and the next line, which starts there, is not\n * touched.\n * - **More than half** — a line you can still read: a soft edge, the strip's surface\n * fading into it over {@link LABEL_EDGE_SOFT_PX}, where 0.24 cut it hard. Never taller\n * than the part of that line already gone, so it grows from nothing as a line starts\n * to move and never reaches the next one.\n * - **Nothing to cover** — at rest, or a line boundary right on the strip's edge:\n * nothing drawn. An unscrolled first line is never covered (at rest the strip covers\n * the top padding and nothing else, as in 0.24), and neither is any whole line.\n *\n * Why not one of the two alone. A plain fade under the strip, as tall as the tails,\n * dims the top of the first WHOLE line whenever it sits right under the strip (its\n * capitals start about 4px into a 20px line), and only half-hides the tails, which sit\n * in its fading part. A plain snap to the line box would hide a whole line the moment\n * one starts to scroll. The half-line threshold keeps what is shown legible: a line is\n * either hidden or shown with most of its letters, never as a row of stray marks.\n *\n * Its height and solid part are `--label-strip-edge` / `--label-strip-edge-solid`,\n * written by {@link TextareaLabelStrip} from the textarea's `scrollTop` and line height\n * on every scroll; unset (no layout, no script yet) the height is 0 and nothing shows.\n * A pseudo-element of the strip, so it inherits everything the strip already gets\n * right: shown only while the label is floated, the same horizontal insets (stopped\n * short of a classic scrollbar, on either side — RTL included), painted under the\n * label. Its colour is INHERITED from the strip (`bg-inherit`): `--bg-surface`, and the\n * disabled / `[readonly]` `--bg-surface-2`, in both themes, with no second copy of\n * those selectors to keep in step. The soft part is a mask over that colour (alpha\n * only — the `#000` is the mask's opacity, not a colour on screen).\n */\nconst TEXTAREA_LABEL_EDGE = cn(\n \"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-[var(--label-strip-edge,0px)] after:content-['']\",\n \"after:bg-inherit after:[mask-image:linear-gradient(#000_var(--label-strip-edge-solid,0px),transparent)]\",\n);\n\n/** The soft edge over a line still more than half in view, in px: \"a few pixels\" —\n * enough to take the cut off its letters, little enough to leave them legible. */\nconst LABEL_EDGE_SOFT_PX = 3;\n\n/**\n * {@link TEXTAREA_LABEL_EDGE}'s height and solid part, in px, for a field scrolled\n * `scrollTop` with lines `lineHeight` tall.\n *\n * The strip covers the top padding exactly, so line boxes meet its lower edge whenever\n * `scrollTop` is a whole number of lines; `gone` is how far the line now on its way out\n * has passed under it, `left` how much of it is still below. A line height that is not\n * a length (`normal`) gives no line boxes to snap to: only the soft edge then.\n */\nfunction labelEdge(scrollTop: number, lineHeight: number): { height: number; solid: number } {\n if (!(scrollTop > 0)) return { height: 0, solid: 0 };\n if (!(lineHeight > 0)) return { height: Math.min(scrollTop, LABEL_EDGE_SOFT_PX), solid: 0 };\n const gone = scrollTop % lineHeight;\n const left = gone === 0 ? 0 : lineHeight - gone;\n if (left > 0 && left <= lineHeight / 2) return { height: left, solid: left };\n return { height: Math.min(gone, LABEL_EDGE_SOFT_PX), solid: 0 };\n}\n\n/**\n * The backdrop of a labelled {@link Textarea}'s label strip (Kurvenschmiede, 0.24).\n *\n * The report: paste a long table into ColumnMapper, the field scrolls, and the floated\n * \"Paste a table\" / \"Tabelle einfügen\" sits ON the first visible line of text — on\n * desktop and on a phone. Not a ColumnMapper bug: every labelled textarea that scrolls\n * did it. An `<input>` has one line that never moves, so the strip FIELD_FLOATING_PAD\n * leaves for the label is always empty there; a textarea's top padding is part of its\n * SCROLLING area, and once the content scrolls the lines travel up through the strip\n * and under the label.\n *\n * The fix makes that padding stay put: this layer is a later sibling of the textarea\n * (so `peer-*` reaches it, and as a positioned box it paints above the textarea's\n * text), the label paints above it in turn, and it covers exactly the top padding —\n * `top-px` / `inset-x-px` inside the 1px border, `h-4` = `pt-4`. At rest it covers\n * padding and nothing else, so no pixel of an unscrolled line is ever hidden; scrolled\n * text slides out of sight under it instead of through the label.\n *\n * Why a layer and not \"start the scrolling area below the strip\" (a wrapper that draws\n * the border and background, the textarea transparent inside it): the box would stop\n * being the textarea. Its focus outline, its brand border on focus, the invalid ring,\n * the disabled and read-only greys and every caller's `[&_textarea]:…` would all have\n * to be rebuilt on the wrapper with `focus-within:` / `has-[…]:`, and a caller's\n * `style={{ height }}` would size the scroller rather than the field. The layer leaves\n * all of that exactly where it was.\n *\n * What it has to match, it matches from the same tokens the field uses: the surface is\n * FIELD_BASE's `--bg-surface`, and `--bg-surface-2` for `disabled` and `[readonly]` —\n * on the same ATTRIBUTE selector FIELD_BASE uses, for the reason given there. Focus and\n * `invalid` change only the border and the ring, which are outside the layer, so they\n * need nothing. Both themes come with the variables. The inner corners are the field's\n * radius (`rounded-md`, 0.375rem) less the 1px border, so the rounded border is never\n * cut into — `calc(0.375rem - 1px)` rather than `var(--radius-md)`, which Tailwind emits\n * only where a utility uses it and the kit's token check refuses (token-vars-declared).\n * Since 0.32 in rem (§3.2): the field's own corner grows with the text, so a fixed 5px\n * left the strip's corner square of the border's at 125 %.\n *\n * Hidden (`display: none`) until the label floats: an empty, unfocused field has\n * nothing that could scroll, and it renders exactly as it did before 0.24. Pointer\n * events pass through, so a click on the strip still lands in the field.\n *\n * The one thing CSS cannot know is a classic scrollbar's width, and a strip across the\n * whole inner width would hide the scrollbar's top arrow — see {@link TextareaLabelStrip}.\n *\n * Its lower edge, where a line scrolled half under it used to leave its tails showing,\n * is {@link TEXTAREA_LABEL_EDGE} (0.25).\n */\nconst TEXTAREA_LABEL_STRIP = cn(\n \"pointer-events-none absolute inset-x-px top-px hidden h-4 rounded-t-[calc(0.375rem-1px)] bg-surface\",\n // Never at Large (0.33, §10.17): the label stands above the field, so there is no\n // strip to keep scrolled text out of, and a layer here would hide the first line.\n \"not-large:peer-focus:block not-large:peer-[:not(:placeholder-shown)]:block\",\n \"peer-disabled:bg-surface-2 peer-[[readonly]]:bg-surface-2\",\n TEXTAREA_LABEL_EDGE,\n);\n\n/**\n * {@link TEXTAREA_LABEL_STRIP}, stopped short of a classic scrollbar.\n *\n * A Windows / Linux desktop draws a textarea's vertical scrollbar INSIDE the border, in\n * a gutter it takes from the content box (15px in Chromium); a strip across the full\n * inner width would sit on top of the scrollbar's up arrow. So the gutter is measured —\n * border box less client width less the borders — and the strip ends where it begins.\n * On whichever side it is: `clientLeft` includes a gutter on the LEFT, which is where\n * Chromium and Firefox put it in a right-to-left field. The side is applied as an\n * inline-start / inline-end inset, so when `dir` flips afterwards and the scrollbar\n * follows it to the other edge, the strip does too without a re-measure. That corner\n * also loses its rounding, since it no longer meets the border.\n *\n * Overlay scrollbars (macOS, phones) take no gutter: the measurement is zero and the\n * strip spans the field. A ResizeObserver on the textarea re-measures when the\n * scrollbar comes or goes (it takes the content box with it) and when the field is\n * resized by hand. Not laid out — hidden in a closed panel, or jsdom — the CSS insets\n * stand until it is.\n *\n * It also writes the strip's lower edge (0.25, {@link TEXTAREA_LABEL_EDGE}) from the\n * textarea's scroll position: a passive `scroll` listener, plus the same observer, since\n * a resize can move the scroll without the user scrolling.\n */\nfunction TextareaLabelStrip() {\n const ref = useRef<HTMLSpanElement>(null);\n useLayoutEffect(() => {\n const strip = ref.current;\n const field = strip?.previousElementSibling;\n if (!strip || !(field instanceof HTMLTextAreaElement)) return;\n const fit = () => {\n if (field.offsetWidth === 0) return;\n const style = getComputedStyle(field);\n const borderLeft = parseFloat(style.borderLeftWidth) || 0;\n const borderRight = parseFloat(style.borderRightWidth) || 0;\n const gutter = field.offsetWidth - field.clientWidth - borderLeft - borderRight;\n // offsetWidth and clientWidth are whole pixels, so a field with no scrollbar can\n // read ±1 here; no scrollbar is that thin.\n const bar = gutter > 1.5;\n const onLeft = field.clientLeft - borderLeft > 1.5;\n // Kept as a LOGICAL side: a `dir` flipped later (a language switch) moves the\n // scrollbar across without resizing anything, so nothing would re-measure —\n // but it stays at the inline end, and so does an inline-end inset.\n const rtl = style.direction === \"rtl\";\n const atEnd = onLeft === rtl;\n const border = (end: boolean) => (end === rtl ? borderLeft : borderRight);\n strip.style.insetInlineEnd = bar && atEnd ? `${border(true) + gutter}px` : \"\";\n strip.style.insetInlineStart = bar && !atEnd ? `${border(false) + gutter}px` : \"\";\n strip.style.borderStartEndRadius = bar && atEnd ? \"0\" : \"\";\n strip.style.borderStartStartRadius = bar && !atEnd ? \"0\" : \"\";\n };\n // The edge under the strip follows the scroll — see TEXTAREA_LABEL_EDGE. The line\n // height is read each time, since a caller's class can change the type. Nothing to\n // draw is no property at all (the CSS default is a 0px edge), so a field at rest\n // carries exactly the strip it did in 0.24.\n const follow = () => {\n const edge = labelEdge(field.scrollTop, parseFloat(getComputedStyle(field).lineHeight));\n if (edge.height > 0) {\n strip.style.setProperty(\"--label-strip-edge\", `${edge.height}px`);\n strip.style.setProperty(\"--label-strip-edge-solid\", `${edge.solid}px`);\n } else {\n strip.style.removeProperty(\"--label-strip-edge\");\n strip.style.removeProperty(\"--label-strip-edge-solid\");\n }\n };\n fit();\n follow();\n field.addEventListener(\"scroll\", follow, { passive: true });\n if (typeof ResizeObserver === \"undefined\") return () => field.removeEventListener(\"scroll\", follow);\n // A resize can move the scroll too (a taller field clamps it).\n const observer = new ResizeObserver(() => {\n fit();\n follow();\n });\n observer.observe(field);\n return () => {\n field.removeEventListener(\"scroll\", follow);\n observer.disconnect();\n };\n }, []);\n return <span ref={ref} aria-hidden data-slot=\"label-strip\" className={TEXTAREA_LABEL_STRIP} />;\n}\n\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement>, FieldHelpProps {\n label?: ReactNode;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`. */\n error?: ReactNode;\n /** See {@link Input}'s `hint` (keksdose K4): text is a caption under the field, a\n * {@link FieldHint} rides the label line (or, unlabelled, the end edge). */\n hint?: ReactNode;\n /** See {@link Input}'s `showCount` (keksdose K11): a live \"12/80\" under the field,\n * with `maxLength`, announced only near and at the limit. */\n showCount?: boolean;\n /** The counter's words. See {@link CharacterCountLabels}. */\n countLabels?: Partial<CharacterCountLabels>;\n /** The `<textarea>`. A React 19 ref prop since 0.34 — see {@link InputProps.ref}. */\n ref?: Ref<HTMLTextAreaElement>;\n}\n\nexport function Textarea(props: TextareaProps) {\n const { ref, className, label, id, placeholder, invalid, error, hint, help, showCount, countLabels, ...rest } = props;\n // See Input's `reserve`.\n const reserve = \"error\" in props || \"hint\" in props || \"showCount\" in props;\n const generated = useId();\n const fieldId = id ?? generated;\n const countId = useId();\n const hintParts = useFieldHint(hint, rest[\"aria-describedby\"], { help, name: label ?? rest[\"aria-label\"] });\n const maxLength = rest.maxLength;\n const counting = Boolean(showCount) && typeof maxLength === \"number\" && maxLength > 0;\n const length = useTypedLength(rest.value, rest.defaultValue);\n const countText = useCharacterCountLabels(countLabels);\n const { isInvalid, describedBy, errorEl } = useFieldError(\n error,\n invalid,\n mergeDescribedBy(hintParts.describedBy, counting && countId),\n rest[\"aria-invalid\"],\n );\n const counter = counting ? (\n <CharacterCount\n id={countId}\n used={length.used}\n max={maxLength}\n labels={countText}\n caption={<FieldCaption parts={hintParts} className=\"mt-0\" />}\n />\n ) : null;\n const below = fieldBelow(hintParts, counter, errorEl);\n const onChange = counting\n ? (e: ChangeEvent<HTMLTextAreaElement>) => {\n length.track(e.target.value);\n rest.onChange?.(e);\n }\n : rest.onChange;\n if (label === undefined) {\n const endHint = hintParts.labelHint;\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n <EndHintRow hint={endHint} className={className}>\n <textarea\n ref={ref}\n id={id}\n placeholder={placeholder}\n {...rest}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onChange={onChange}\n className={cn(FIELD_BASE, endHint === undefined && className, isInvalid && FIELD_INVALID)}\n />\n </EndHintRow>\n </FieldGroup>\n );\n }\n const ownPlaceholder = hasPlaceholderText(placeholder);\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n <FloatingField className={className} htmlFor={fieldId} label={label} hint={hintParts.labelHint}>\n <textarea\n ref={ref}\n id={fieldId}\n // The float trick's single space, unless the caller has a placeholder of its\n // own — then that, shown on focus once the label is out of its way (kastlan\n // 8, see FLOATING_PLACEHOLDER_ON_FOCUS).\n placeholder={ownPlaceholder ? placeholder : \" \"}\n {...rest}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n onChange={onChange}\n className={cn(\n FLOATING_INPUT_CLASS,\n ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS,\n isInvalid && FIELD_INVALID,\n )}\n />\n {/* Between the textarea and the label, on purpose: after the textarea so it\n is its `peer` and paints over its text, before the label so the label\n paints over it. See TEXTAREA_LABEL_STRIP. */}\n <TextareaLabelStrip />\n </FloatingField>\n </FieldGroup>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ComponentPropsWithoutRef, ReactElement, ReactNode, Ref } from "react";
|
|
2
2
|
import { type CommitScope } from "./write-lock.js";
|
|
3
|
-
import { type DisabledReasonDisplay } from "./field-parts.js";
|
|
3
|
+
import { type DisabledReasonDisplay, type FieldHelpProps } from "./field-parts.js";
|
|
4
4
|
export interface ToggleOption<T extends string> {
|
|
5
5
|
value: T;
|
|
6
6
|
label: string;
|
|
@@ -15,7 +15,7 @@ export interface ToggleOption<T extends string> {
|
|
|
15
15
|
* accepted and the component ignored would be a prop that silently does nothing —
|
|
16
16
|
* worse than one that does not compile.
|
|
17
17
|
*/
|
|
18
|
-
export interface ToggleGroupBaseProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children"
|
|
18
|
+
export interface ToggleGroupBaseProps<T extends string> extends Omit<ComponentPropsWithoutRef<"div">, "onChange" | "children">, FieldHelpProps {
|
|
19
19
|
options: ToggleOption<T>[];
|
|
20
20
|
className?: string;
|
|
21
21
|
/** Applied to every option button (e.g. to tune height/rounding to match
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DISABLED_REASON_LINE_CLASS,
|
|
13
13
|
FIELD_CAPTION_CLASS,
|
|
14
14
|
FIELD_ERROR_CLASS,
|
|
15
|
+
fieldHelpMark,
|
|
15
16
|
isTextHint,
|
|
16
17
|
LabelStrip,
|
|
17
18
|
useDisabledReasonLine,
|
|
@@ -42,6 +43,7 @@ function ToggleGroup(props) {
|
|
|
42
43
|
labelPlacement = "field",
|
|
43
44
|
chromeClassName,
|
|
44
45
|
hint,
|
|
46
|
+
help,
|
|
45
47
|
error,
|
|
46
48
|
disabledReason: ownDisabledReason,
|
|
47
49
|
disabledReasonDisplay,
|
|
@@ -64,9 +66,11 @@ function ToggleGroup(props) {
|
|
|
64
66
|
const disabledReason = useCommitReason(commit, ownDisabledReason);
|
|
65
67
|
const locked = hasContent(disabledReason);
|
|
66
68
|
const reasonLine = useDisabledReasonLine(disabledReasonDisplay) && locked;
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
69
|
+
const helpMark = fieldHelpMark(hasContent(label) ? help : void 0, label);
|
|
70
|
+
const withHelp = helpMark !== void 0;
|
|
71
|
+
const inlineHint = useInlineHint(hint, withHelp);
|
|
72
|
+
const hintAsCaption = withHelp ? hasContent(inlineHint) ? inlineHint : void 0 : isTextHint(hint) ? hint : inlineHint !== hint ? inlineHint : void 0;
|
|
73
|
+
const labelHint = helpMark ?? (hintAsCaption === void 0 ? hint : void 0);
|
|
70
74
|
const dimmed = disabled || locked;
|
|
71
75
|
const labelled = hasContent(label);
|
|
72
76
|
const field = labelled && labelPlacement === "field";
|