@godxjp/ui 32.0.4 → 32.1.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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/agent/START-HERE.md +7 -7
  3. package/agent/components/Checkbox.json +1 -1
  4. package/agent/components/CheckboxGroup.json +6 -0
  5. package/agent/components/CommandPalette.json +6 -0
  6. package/agent/components/DatePicker.json +6 -0
  7. package/agent/components/DropdownMenu.json +5 -0
  8. package/agent/components/PasswordStrength.json +3 -3
  9. package/agent/components/SearchInput.json +6 -0
  10. package/agent/components/Select.json +6 -0
  11. package/agent/components/Toaster.json +15 -4
  12. package/agent/components-index.json +1 -1
  13. package/agent/components.json +54 -8
  14. package/agent/index.json +6 -6
  15. package/agent/llms.txt +6 -6
  16. package/agent/patterns/card-grid-selection.json +21 -0
  17. package/agent/patterns/dependent-selects.json +20 -0
  18. package/agent/patterns-index.json +39 -0
  19. package/agent/patterns.json +41 -0
  20. package/agent/tokens.json +18 -0
  21. package/dist/components/data-entry/checkbox-group.d.ts +1 -1
  22. package/dist/components/data-entry/checkbox-group.js +58 -19
  23. package/dist/components/data-entry/command-palette.d.ts +21 -1
  24. package/dist/components/data-entry/command-palette.js +34 -34
  25. package/dist/components/data-entry/date-picker.js +21 -1
  26. package/dist/components/data-entry/index.d.ts +1 -1
  27. package/dist/components/data-entry/password-strength.d.ts +2 -0
  28. package/dist/components/data-entry/password-strength.js +8 -7
  29. package/dist/components/data-entry/search-input.d.ts +1 -1
  30. package/dist/components/data-entry/search-input.js +22 -2
  31. package/dist/components/data-entry/search-select.js +63 -11
  32. package/dist/components/feedback/sonner.d.ts +7 -1
  33. package/dist/components/feedback/sonner.js +22 -2
  34. package/dist/components/feedback/use-toast.d.ts +3 -1
  35. package/dist/components/feedback/use-toast.js +10 -1
  36. package/dist/components/navigation/dropdown-menu.d.ts +7 -1
  37. package/dist/components/navigation/dropdown-menu.js +18 -2
  38. package/dist/contracts/measurement.json +1 -1
  39. package/dist/i18n/messages/en.json +19 -1
  40. package/dist/i18n/messages/ja.json +18 -1
  41. package/dist/i18n/messages/vi.json +18 -1
  42. package/dist/lib/shortcut.d.ts +17 -0
  43. package/dist/lib/shortcut.js +37 -0
  44. package/dist/props/components/data-entry.prop.d.ts +36 -0
  45. package/dist/styles/control.css +23 -0
  46. package/dist/styles/dialog-layout.css +4 -1
  47. package/dist/styles/layers.json +6 -1
  48. package/dist/styles/navigation-layout.css +26 -0
  49. package/dist/styles/table-layout.css +14 -0
  50. package/dist/styles.css +1 -1
  51. package/dist/tokens/components/navigation.css +6 -0
  52. package/dist/tokens/foundation.css +2 -0
  53. package/package.json +2 -2
@@ -91,6 +91,45 @@
91
91
  "delete"
92
92
  ]
93
93
  },
94
+ {
95
+ "aliases": [
96
+ "cascading-selects",
97
+ "country-state-city",
98
+ "chained-selects",
99
+ "linked-selects"
100
+ ],
101
+ "name": "dependent-selects",
102
+ "tagline": "Country → state → city: three Selects where each child is disabled until its parent has a value (FormField helper says why), loading while its options fetch, and reset to \"\" when the parent changes. A composition, not a component — reach for Cascader only when it is ONE field.",
103
+ "tags": [
104
+ "form",
105
+ "select",
106
+ "dependent",
107
+ "cascading",
108
+ "address",
109
+ "loading",
110
+ "reset"
111
+ ]
112
+ },
113
+ {
114
+ "aliases": [
115
+ "card-selection",
116
+ "selectable-cards",
117
+ "bulk-delete-cards",
118
+ "grid-bulk-actions"
119
+ ],
120
+ "name": "card-grid-selection",
121
+ "tagline": "Select cards in a grid and act on them in bulk — Card + Checkbox per item, the real DataTable.BulkActions driven by count outside a table, indeterminate select-all, AlertDialog before delete, toast with Undo after, EmptyState when nothing is left.",
122
+ "tags": [
123
+ "selection",
124
+ "bulk",
125
+ "cards",
126
+ "grid",
127
+ "delete",
128
+ "undo",
129
+ "empty",
130
+ "checkbox"
131
+ ]
132
+ },
94
133
  {
95
134
  "name": "deferred-loading",
96
135
  "tagline": "Inertia deferred props with a Skeleton fallback — SkeletonTable while data loads, then DataTable (real @godxjp/ui API).",
@@ -97,6 +97,47 @@
97
97
  "delete"
98
98
  ]
99
99
  },
100
+ {
101
+ "aliases": [
102
+ "cascading-selects",
103
+ "country-state-city",
104
+ "chained-selects",
105
+ "linked-selects"
106
+ ],
107
+ "code": "// Live page: /showcase/form-dependent-selects (docs/showcase/form-dependent-selects.tsx).\n// Three rules: (1) child disabled until parent has a value + helper saying why;\n// (2) child loading while its options fetch; (3) parent change resets EVERY descendant to \"\".\n// Reset to \"\", Select's controlled empty value — undefined flips it to uncontrolled.\nimport * as React from \"react\";\nimport { Form, FormField, Select } from \"@godxjp/ui/data-entry\";\nimport { Flex } from \"@godxjp/ui/layout\";\nimport { useTranslation } from \"@godxjp/ui/i18n\";\n\ntype Option = { value: string; label: string };\ntype Address = { country: string; region: string; city: string };\n\n// Fetch options for a parent value. The answer is stored WITH the parent it answers, so\n// `loading` is derived and a slow answer for an OLD parent is never shown on the new one.\nfunction useChildOptions(parent: string, fetchOptions: (parent: string) => Promise<Option[]>) {\n const [answer, setAnswer] = React.useState<{ parent: string; options: Option[] }>({ parent: \"\", options: [] });\n React.useEffect(() => {\n if (!parent) return;\n let live = true;\n void fetchOptions(parent).then((options) => { if (live) setAnswer({ parent, options }); });\n return () => { live = false; };\n }, [parent, fetchOptions]);\n const ready = parent !== \"\" && answer.parent === parent;\n return { options: ready ? answer.options : [], loading: parent !== \"\" && !ready };\n}\n\nexport function AddressFields({\n fetchRegions,\n fetchCities,\n}: {\n fetchRegions: (country: string) => Promise<Option[]>; // stable (module-level or useCallback)\n fetchCities: (region: string) => Promise<Option[]>;\n}) {\n const { t, locale } = useTranslation();\n const [value, setValue] = React.useState<Address>({ country: \"\", region: \"\", city: \"\" });\n const regions = useChildOptions(value.country, fetchRegions);\n const cities = useChildOptions(value.region, fetchCities);\n\n // ISO 3166-1 alpha-2 → the reader's language, no catalogue needed.\n const countries = React.useMemo(() => {\n const names = new Intl.DisplayNames([locale], { type: \"region\" });\n return [\"JP\", \"US\", \"VN\"].map((code) => ({ value: code, label: names.of(code) ?? code }));\n }, [locale]);\n\n return (\n <Form controlWidth=\"20rem\">\n <Flex direction=\"col\" gap=\"md\">\n <FormField id=\"country\" label={t(\"address.country\")}>\n <Select\n value={value.country}\n onValueChange={(country: string) => setValue({ country, region: \"\", city: \"\" })}\n options={countries}\n placeholder={t(\"address.countryPlaceholder\")}\n />\n </FormField>\n <FormField\n id=\"region\"\n label={t(\"address.region\")}\n helper={value.country ? undefined : t(\"address.selectCountryFirst\")}\n >\n <Select\n value={value.region}\n onValueChange={(region: string) => setValue({ ...value, region, city: \"\" })}\n options={regions.options}\n disabled={!value.country}\n loading={regions.loading}\n placeholder={t(\"address.regionPlaceholder\")}\n />\n </FormField>\n <FormField\n id=\"city\"\n label={t(\"address.city\")}\n helper={value.region ? undefined : t(\"address.selectRegionFirst\")}\n >\n <Select\n value={value.city}\n onValueChange={(city: string) => setValue({ ...value, city })}\n options={cities.options}\n disabled={!value.region}\n loading={cities.loading}\n placeholder={t(\"address.cityPlaceholder\")}\n />\n </FormField>\n </Flex>\n </Form>\n );\n}",
108
+ "name": "dependent-selects",
109
+ "tagline": "Country → state → city: three Selects where each child is disabled until its parent has a value (FormField helper says why), loading while its options fetch, and reset to \"\" when the parent changes. A composition, not a component — reach for Cascader only when it is ONE field.",
110
+ "tags": [
111
+ "form",
112
+ "select",
113
+ "dependent",
114
+ "cascading",
115
+ "address",
116
+ "loading",
117
+ "reset"
118
+ ]
119
+ },
120
+ {
121
+ "aliases": [
122
+ "card-selection",
123
+ "selectable-cards",
124
+ "bulk-delete-cards",
125
+ "grid-bulk-actions"
126
+ ],
127
+ "code": "// Live page: /showcase/card-grid-selection (docs/showcase/card-grid-selection.tsx).\n// DataTable.BulkActions reads the table context only when one exists — outside a table pass\n// `count`: it renders nothing at 0, and the localised \"N selected\" + a labelled region above 0.\n// The Checkbox's inline label is the item name: the name is the click target and the\n// accessible name. Do NOT make the whole Card an onClick div.\nimport * as React from \"react\";\nimport { FolderOpen, Trash2 } from \"lucide-react\";\nimport { Card, CardContent, DataTable, EmptyState } from \"@godxjp/ui/data-display\";\nimport { Checkbox } from \"@godxjp/ui/data-entry\";\nimport { AlertDialog, Toaster, toast } from \"@godxjp/ui/feedback\";\nimport { Button } from \"@godxjp/ui/general\";\nimport { Flex, ResponsiveGrid } from \"@godxjp/ui/layout\";\nimport { useTranslation } from \"@godxjp/ui/i18n\";\n\ntype Item = { id: string; name: string };\n\nexport function SelectableCardGrid({ source }: { source: Item[] }) {\n const { t } = useTranslation();\n const [items, setItems] = React.useState(source);\n const [selected, setSelected] = React.useState<Set<string>>(new Set());\n const [confirmOpen, setConfirmOpen] = React.useState(false);\n const [confirmCount, setConfirmCount] = React.useState(0); // frozen: selection clears on confirm\n\n const all = items.length > 0 && items.every((i) => selected.has(i.id));\n const selectAll: boolean | \"indeterminate\" = all ? true : selected.size > 0 ? \"indeterminate\" : false;\n\n const deleteSelected = () => {\n const removed = new Set(selected);\n setItems((cur) => cur.filter((i) => !removed.has(i.id)));\n setSelected(new Set());\n toast(t(\"files.deleted\", { count: removed.size }), {\n action: {\n label: t(\"files.undo\"),\n // restore in the SOURCE order, not appended at the end\n onClick: () => setItems((cur) => source.filter((i) => removed.has(i.id) || cur.some((c) => c.id === i.id))),\n },\n });\n };\n\n if (items.length === 0) {\n return (\n <EmptyState\n icon={FolderOpen}\n variant=\"section\"\n title={t(\"files.emptyTitle\")}\n action={<Button variant=\"outline\" onClick={() => setItems(source)}>{t(\"files.reset\")}</Button>}\n />\n );\n }\n\n return (\n <Flex direction=\"col\" gap=\"md\">\n <Flex direction=\"row\" align=\"center\" gap=\"md\" wrap>\n <Checkbox\n checked={selectAll}\n onCheckedChange={(next) => setSelected(next === true ? new Set(items.map((i) => i.id)) : new Set())}\n >\n {t(\"files.selectAll\")}\n </Checkbox>\n <DataTable.BulkActions count={selected.size}>\n <Button variant=\"destructive\" size=\"sm\" onClick={() => { setConfirmCount(selected.size); setConfirmOpen(true); }}>\n <Trash2 aria-hidden=\"true\" />\n {t(\"files.delete\")}\n </Button>\n </DataTable.BulkActions>\n </Flex>\n <ResponsiveGrid columns={{ base: 1, sm: 2, lg: 3 }}>\n {items.map((item) => (\n <Card key={item.id} className={selected.has(item.id) ? \"border-primary\" : undefined}>\n <CardContent>\n <Checkbox\n checked={selected.has(item.id)}\n onCheckedChange={(on) =>\n setSelected((prev) => {\n const next = new Set(prev);\n if (on === true) next.add(item.id); else next.delete(item.id);\n return next;\n })\n }\n >\n {item.name}\n </Checkbox>\n </CardContent>\n </Card>\n ))}\n </ResponsiveGrid>\n <AlertDialog\n open={confirmOpen}\n onOpenChange={setConfirmOpen}\n variant=\"destructive\"\n title={t(\"files.confirmTitle\", { count: confirmCount })}\n confirmLabel={t(\"files.delete\")}\n onConfirm={deleteSelected}\n />\n <Toaster />{/* once per app — usually in the root layout, not here */}\n </Flex>\n );\n}",
128
+ "name": "card-grid-selection",
129
+ "tagline": "Select cards in a grid and act on them in bulk — Card + Checkbox per item, the real DataTable.BulkActions driven by count outside a table, indeterminate select-all, AlertDialog before delete, toast with Undo after, EmptyState when nothing is left.",
130
+ "tags": [
131
+ "selection",
132
+ "bulk",
133
+ "cards",
134
+ "grid",
135
+ "delete",
136
+ "undo",
137
+ "empty",
138
+ "checkbox"
139
+ ]
140
+ },
100
141
  {
101
142
  "code": "// Server (Laravel): defer the heavy prop\n// Inertia::render('crm/coupons/index', [\n// 'coupons' => Inertia::defer(fn () => Coupon::all()),\n// ]);\nimport { Card, CardContent, DataTable } from \"@godxjp/ui/data-display\";\nimport type { ColumnDef } from \"@godxjp/ui/data-display\";\nimport { SkeletonTable } from \"@godxjp/ui/feedback\";\n\ntype Coupon = { id: string; name: string };\nconst columns: ColumnDef<Coupon>[] = [{ key: \"name\", header: \"クーポン名\" }];\n\n// coupons is undefined until the deferred request resolves\nexport default function Coupons({ coupons }: { coupons?: Coupon[] }) {\n return (\n <Card>\n <CardContent flush>\n {!coupons\n ? <SkeletonTable rows={10} columns={6} />\n : <DataTable data={coupons} columns={columns} getRowId={(c) => c.id} />}\n </CardContent>\n </Card>\n );\n}",
102
143
  "name": "deferred-loading",
package/agent/tokens.json CHANGED
@@ -1241,6 +1241,12 @@
1241
1241
  "tier": "foundation",
1242
1242
  "value": "160ms"
1243
1243
  },
1244
+ {
1245
+ "description": "How long a toast stays before it dismisses itself (gh#1253). In the MOTION tier, like `--activity-interval`: a component-token name has no property word for a duration. Sonner takes `duration` as a JS number, so `Toaster` reads this ONCE on mount with getComputedStyle — from its ThemeScope host when it has one, else `<html>` — and accepts `ms` or `s`. A theme override applies to toasts raised after the Toaster mounts; a per-call `duration` or the Toaster's own `duration` prop still wins. 4000ms is Sonner's own default.",
1246
+ "name": "--toast-duration",
1247
+ "tier": "foundation",
1248
+ "value": "4000ms"
1249
+ },
1244
1250
  {
1245
1251
  "description": "MARQUEE — the TRAVERSAL member of the loop family, and deliberately NOT `--duration-loop`. `--duration-loop` is 1400ms because it times a BREATH: a dot rising and falling, a bar sweeping a small box. A marquee's cycle is a distance being crossed, two orders of magnitude longer, and the two are not the same knob — binding them would mean a service that calms its typing indicator to 2s also whips its logo wall across the screen twice a second. Same tier, same naming, separate value. It is a duration PER SCREENFUL, not per cycle: the component measures one copy of the content and sets `--marquee-cycle-scale` to (copy width ÷ viewport width), so the duration of one lap is `interval × scale` and the PACE is the same whether the track carries one item or forty. A per-cycle duration would make forty logos forty times faster than one, which is how every pure-CSS marquee behaves and why none of them survive real content. 20s per screenful ≈ 60px/s at 1200px — the resting pace a logo wall reads at. The two ordinals are the `speed` prop's `slow` and `fast`; they are tokens rather than multipliers in the stylesheet so a theme can retune the ladder, not just its middle rung.",
1246
1252
  "name": "--marquee-interval",
@@ -9317,6 +9323,18 @@
9317
9323
  "tier": "component",
9318
9324
  "value": "var(--font-size-base)"
9319
9325
  },
9326
+ {
9327
+ "description": "`DropdownMenuItem description` (gh#1246): the muted second line takes the shortcut's xs step, and a two-line row swaps its fixed height for this block padding.",
9328
+ "name": "--menu-item-description-font-size",
9329
+ "tier": "component",
9330
+ "value": "var( --font-size-xs, calc(var(--font-size-base) / var(--font-size-ratio)) )"
9331
+ },
9332
+ {
9333
+ "description": "Navigation primitive tokens: pagination, filters, compact pickers.",
9334
+ "name": "--menu-item-description-space-block",
9335
+ "tier": "component",
9336
+ "value": "var(--space-1)"
9337
+ },
9320
9338
  {
9321
9339
  "description": "Inset rows (a checkbox/radio row without its own indicator) reserve the indicator column so labels stay aligned with their checked siblings.",
9322
9340
  "name": "--menu-item-inset-space-inline-start",
@@ -1,4 +1,4 @@
1
1
  import * as React from "react";
2
2
  import type { CheckboxGroupProp } from "../../props/components/data-entry.prop.js";
3
3
  export type { CheckboxGroupProp, CheckboxGroupProp as CheckboxGroupProps, } from "../../props/components/data-entry.prop.js";
4
- export declare function CheckboxGroup({ value: controlledValue, defaultValue, onValueChange, options, orientation, disabled, name, id, className, children, ...ariaProps }: CheckboxGroupProp): React.JSX.Element;
4
+ export declare function CheckboxGroup({ value: controlledValue, defaultValue, onValueChange, options, orientation, disabled, name, id, className, children, selectAll, ...ariaProps }: CheckboxGroupProp): React.JSX.Element;
@@ -1,10 +1,12 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cn } from "../../lib/utils.js";
5
5
  import { pickGroupFieldA11y, useFieldIdentity } from "../../lib/field-a11y.js";
6
6
  import { Checkbox } from "./checkbox.js";
7
7
  import { Field } from "./field.js";
8
+ import { Separator } from "../layout/separator.js";
9
+ import { useTranslation } from "../../i18n/use-translation.js";
8
10
  import { choiceGroupClassName } from "./choice-option.js";
9
11
  function useControllableArray(controlled, defaultValue) {
10
12
  const [internal, setInternal] = React.useState(defaultValue);
@@ -28,8 +30,10 @@ function CheckboxGroup({
28
30
  id,
29
31
  className,
30
32
  children,
33
+ selectAll,
31
34
  ...ariaProps
32
35
  }) {
36
+ const { t } = useTranslation();
33
37
  const reactId = React.useId();
34
38
  const [value, setValue] = useControllableArray(controlledValue, defaultValue);
35
39
  const groupA11y = pickGroupFieldA11y(ariaProps);
@@ -42,8 +46,18 @@ function CheckboxGroup({
42
46
  setValue(next);
43
47
  onValueChange?.(next);
44
48
  };
49
+ const enabledValues = (options ?? []).filter((opt) => !opt.disabled).map((opt) => opt.value);
50
+ const selectedEnabled = enabledValues.filter((v) => value.includes(v)).length;
51
+ const allState = enabledValues.length > 0 && selectedEnabled === enabledValues.length ? true : selectedEnabled > 0 ? "indeterminate" : false;
52
+ const toggleAll = () => {
53
+ const next = allState === true ? value.filter((v) => !enabledValues.includes(v)) : [...value, ...enabledValues.filter((v) => !value.includes(v))];
54
+ setValue(next);
55
+ onValueChange?.(next);
56
+ };
57
+ const selectAllId = id ? `${id}-select-all` : `${reactId}-select-all`;
58
+ const selectAllLabel = typeof selectAll === "object" && selectAll.label != null ? selectAll.label : t("dataEntry.checkboxGroup.selectAll");
45
59
  if (options && options.length > 0) {
46
- return /* @__PURE__ */ jsx(
60
+ return /* @__PURE__ */ jsxs(
47
61
  "div",
48
62
  {
49
63
  role: "group",
@@ -53,24 +67,49 @@ function CheckboxGroup({
53
67
  "aria-disabled": disabled ? true : void 0,
54
68
  "data-orientation": orientation,
55
69
  className: choiceGroupClassName(orientation, className),
56
- children: options.map((opt, index) => {
57
- const optionId = optionDomId(opt.value, index);
58
- const checked = value.includes(opt.value);
59
- return /* @__PURE__ */ jsx(Field, { id: optionId, label: opt.label, description: opt.description, children: /* @__PURE__ */ jsx(
60
- Checkbox,
61
- {
62
- id: optionId,
63
- name: resolvedName,
64
- "data-field": resolvedField,
65
- value: opt.value,
66
- checked,
67
- disabled: Boolean(disabled) || Boolean(opt.disabled),
68
- onCheckedChange: () => {
69
- toggle(opt.value);
70
+ children: [
71
+ selectAll ? /* @__PURE__ */ jsxs(Fragment, { children: [
72
+ /* @__PURE__ */ jsx(
73
+ Field,
74
+ {
75
+ id: selectAllId,
76
+ label: selectAllLabel,
77
+ description: t("common.selectedOfTotal", {
78
+ count: selectedEnabled,
79
+ total: enabledValues.length
80
+ }),
81
+ children: /* @__PURE__ */ jsx(
82
+ Checkbox,
83
+ {
84
+ id: selectAllId,
85
+ checked: allState,
86
+ disabled: Boolean(disabled) || enabledValues.length === 0,
87
+ onCheckedChange: toggleAll
88
+ }
89
+ )
90
+ }
91
+ ),
92
+ /* @__PURE__ */ jsx(Separator, {})
93
+ ] }) : null,
94
+ options.map((opt, index) => {
95
+ const optionId = optionDomId(opt.value, index);
96
+ const checked = value.includes(opt.value);
97
+ return /* @__PURE__ */ jsx(Field, { id: optionId, label: opt.label, description: opt.description, children: /* @__PURE__ */ jsx(
98
+ Checkbox,
99
+ {
100
+ id: optionId,
101
+ name: resolvedName,
102
+ "data-field": resolvedField,
103
+ value: opt.value,
104
+ checked,
105
+ disabled: Boolean(disabled) || Boolean(opt.disabled),
106
+ onCheckedChange: () => {
107
+ toggle(opt.value);
108
+ }
70
109
  }
71
- }
72
- ) }, opt.value);
73
- })
110
+ ) }, opt.value);
111
+ })
112
+ ]
74
113
  }
75
114
  );
76
115
  }
@@ -27,6 +27,21 @@ export type CommandPaletteLabels = {
27
27
  select?: React.ReactNode;
28
28
  close?: React.ReactNode;
29
29
  };
30
+ /**
31
+ * Recent queries the CONSUMER keeps (gh#1249). The palette only renders them — it never reads or
32
+ * writes `localStorage` or any other store, and it renders `items` exactly as given (de-duplicate
33
+ * and cap them before passing them in).
34
+ */
35
+ export type CommandPaletteRecent = {
36
+ /** Most recent first. Duplicates are rendered as given. */
37
+ items: string[];
38
+ /** A recent query was chosen. The palette closes, like any other selection. */
39
+ onSelect: (query: string) => void;
40
+ /** The trailing "Clear recent searches" row was chosen. The palette stays open. */
41
+ onClear: () => void;
42
+ /** Group heading. Default: `t("dataEntry.commandPalette.recent")`. */
43
+ label?: React.ReactNode;
44
+ };
30
45
  /** The modifier keys held while an item was chosen (gh#1126). */
31
46
  export type CommandPaletteSelectModifiers = {
32
47
  metaKey: boolean;
@@ -90,6 +105,11 @@ export type CommandPaletteProps = {
90
105
  * `mod+shift+p` are different shortcuts. Never fires during IME composition.
91
106
  */
92
107
  shortcut?: boolean | string;
108
+ /**
109
+ * Recent queries (gh#1249), shown as the FIRST group only while the query is empty, with a
110
+ * trailing "Clear recent searches" row. Storage stays with the consumer.
111
+ */
112
+ recent?: CommandPaletteRecent;
93
113
  };
94
114
  /**
95
115
  * Unchanged. - **`shouldFilter={false}` (server-side)** — the PALETTE owns it, derived
@@ -100,4 +120,4 @@ export type CommandPaletteProps = {
100
120
  * it races. Reading `groups` instead makes "did we render the empty state?" a pure function of the
101
121
  * props at that render, which is what a contract test can assert.
102
122
  */
103
- export declare function CommandPalette({ groups, labels, onSelect, open: controlledOpen, defaultOpen, onOpenChange, search: controlledSearch, defaultSearch, onSearchChange, shouldFilter, loading, error, trigger, shortcut, }: CommandPaletteProps): React.JSX.Element;
123
+ export declare function CommandPalette({ groups, labels, onSelect, open: controlledOpen, defaultOpen, onOpenChange, search: controlledSearch, defaultSearch, onSearchChange, shouldFilter, loading, error, trigger, shortcut, recent, }: CommandPaletteProps): React.JSX.Element;
@@ -12,39 +12,9 @@ import {
12
12
  CommandItem,
13
13
  CommandList
14
14
  } from "./command.js";
15
- import { isApplePlatform } from "../../lib/platform.js";
15
+ import { useTranslation } from "../../i18n/use-translation.js";
16
16
  import { isImeComposing } from "../../lib/ime.js";
17
- function parseShortcut(shortcut) {
18
- if (shortcut === false) return null;
19
- const parts = (shortcut === true ? "mod+k" : shortcut).toLowerCase().split("+").map((p) => p.trim());
20
- const key = parts.pop() ?? "";
21
- if (!key) return null;
22
- return {
23
- key,
24
- mod: parts.includes("mod"),
25
- meta: parts.includes("meta") || parts.includes("cmd"),
26
- ctrl: parts.includes("ctrl"),
27
- shift: parts.includes("shift"),
28
- alt: parts.includes("alt") || parts.includes("option")
29
- };
30
- }
31
- function shortcutMatches(spec, event) {
32
- const keyMatches = event.key.toLowerCase() === spec.key || /^[a-z]$/.test(spec.key) && event.code === `Key${spec.key.toUpperCase()}`;
33
- if (!keyMatches) return false;
34
- if (spec.mod && !(isApplePlatform() ? event.metaKey : event.ctrlKey)) return false;
35
- if (spec.meta && !event.metaKey) return false;
36
- if (spec.ctrl && !event.ctrlKey) return false;
37
- return event.shiftKey === spec.shift && event.altKey === spec.alt;
38
- }
39
- function shortcutHint(spec) {
40
- return [
41
- spec.meta || spec.mod && isApplePlatform() ? "\u2318" : "",
42
- spec.ctrl || spec.mod && !isApplePlatform() ? "Ctrl+" : "",
43
- spec.alt ? "\u2325" : "",
44
- spec.shift ? "\u21E7" : "",
45
- spec.key.length === 1 ? spec.key.toUpperCase() : spec.key
46
- ].join("");
47
- }
17
+ import { parseShortcut, shortcutHint, shortcutMatches } from "../../lib/shortcut.js";
48
18
  const modifiersOf = (event) => ({
49
19
  metaKey: event.metaKey,
50
20
  ctrlKey: event.ctrlKey,
@@ -71,8 +41,10 @@ function CommandPalette({
71
41
  loading = false,
72
42
  error,
73
43
  trigger,
74
- shortcut = true
44
+ shortcut = true,
45
+ recent
75
46
  }) {
47
+ const { t } = useTranslation();
76
48
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
77
49
  const [uncontrolledSearch, setUncontrolledSearch] = React.useState(defaultSearch);
78
50
  const shortcutRestoreFocusRef = React.useRef(null);
@@ -120,6 +92,7 @@ function CommandPalette({
120
92
  return () => document.removeEventListener("keydown", handleKeyDown);
121
93
  }, [open, setOpen, shortcutSpec]);
122
94
  const hasItems = React.useMemo(() => groups.some((group) => group.items.length > 0), [groups]);
95
+ const showRecent = recent != null && recent.items.length > 0 && search === "";
123
96
  const triggerNode = trigger === void 0 ? /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", className: "ui-command-palette-trigger", children: [
124
97
  /* @__PURE__ */ jsx(Search, { "aria-hidden": "true" }),
125
98
  /* @__PURE__ */ jsx("span", { children: labels.open }),
@@ -172,11 +145,38 @@ function CommandPalette({
172
145
  }
173
146
  ),
174
147
  /* @__PURE__ */ jsx(CommandList, { "aria-busy": loading, children: loading ? /* @__PURE__ */ jsx("div", { className: "ui-command-palette-state", role: "status", children: labels.loading }) : error ? /* @__PURE__ */ jsx("div", { className: "ui-command-palette-state", role: "alert", children: error }) : /* @__PURE__ */ jsxs(Fragment, { children: [
175
- shouldFilter ? /* @__PURE__ */ jsx(CommandEmpty, { children: labels.empty }) : hasItems ? null : (
148
+ shouldFilter ? /* @__PURE__ */ jsx(CommandEmpty, { children: labels.empty }) : hasItems || showRecent ? null : (
176
149
  // Same node cmdk would have rendered (class + role), decided from `groups`
177
150
  // instead of from its scheduled DOM-registration count — see the doc block.
178
151
  /* @__PURE__ */ jsx("div", { className: "ui-command-empty", role: "presentation", children: labels.empty })
179
152
  ),
153
+ showRecent ? /* @__PURE__ */ jsxs(CommandGroup, { heading: recent.label ?? t("dataEntry.commandPalette.recent"), children: [
154
+ recent.items.map((query, index) => /* @__PURE__ */ jsx(
155
+ CommandItem,
156
+ {
157
+ value: `recent:${index}:${query}`,
158
+ onSelect: () => {
159
+ modifiersRef.current = NO_MODIFIERS;
160
+ setOpen(false);
161
+ recent.onSelect(query);
162
+ },
163
+ children: /* @__PURE__ */ jsx("span", { className: "ui-command-palette-label", children: query })
164
+ },
165
+ `${index}:${query}`
166
+ )),
167
+ /* @__PURE__ */ jsx(
168
+ CommandItem,
169
+ {
170
+ value: "recent:clear",
171
+ className: "ui-command-palette-recent-clear",
172
+ onSelect: () => {
173
+ modifiersRef.current = NO_MODIFIERS;
174
+ recent.onClear();
175
+ },
176
+ children: t("dataEntry.commandPalette.clearRecent")
177
+ }
178
+ )
179
+ ] }) : null,
180
180
  groups.map((group) => /* @__PURE__ */ jsx(CommandGroup, { heading: group.label, children: group.items.map((item) => /* @__PURE__ */ jsxs(
181
181
  CommandItem,
182
182
  {
@@ -13,6 +13,7 @@ import {
13
13
  } from "../../lib/datetime/picker-format.js";
14
14
  import { Button } from "../general/button.js";
15
15
  import { Flex } from "../layout/flex.js";
16
+ import { Text } from "../general/typography.js";
16
17
  import { TimePicker } from "./time-picker.js";
17
18
  import { useControlledLatch } from "../../lib/hooks.js";
18
19
  import { pickFieldA11y, pickGroupFieldA11y, useFieldIdentity } from "../../lib/field-a11y.js";
@@ -75,6 +76,7 @@ function DatePicker(props) {
75
76
  preserveInvalidOnBlur,
76
77
  placement = "bottom-start",
77
78
  renderExtraFooter,
79
+ showSummary = true,
78
80
  size,
79
81
  status,
80
82
  variant,
@@ -166,6 +168,7 @@ function DatePicker(props) {
166
168
  React.useEffect(() => {
167
169
  if (open) setInternalView(defaultPickerValue ?? valueAnchor);
168
170
  }, [open]);
171
+ const halfRange = Boolean(rangeValue?.from && !rangeValue.to);
169
172
  const resolvedPlaceholder = placeholder ?? (isPeriod ? t("dataEntry.monthPicker.placeholder") : range ? t("dataEntry.dateRangePicker.placeholder") : t("dataEntry.datePicker.placeholder")) ?? PERIOD_HINT[picker];
170
173
  const clearControl = resolveAllowClear(allowClear, true, t("common.clear") ?? "Clear");
171
174
  const showClear = clearControl.enabled && !anyDisabled && (range ? allowEmpty.every(Boolean) && Boolean(committedRange?.from || committedRange?.to) : text !== "");
@@ -389,6 +392,22 @@ function DatePicker(props) {
389
392
  ) }, index);
390
393
  }) }, row)) })
391
394
  ] });
395
+ const renderSummary = () => {
396
+ const from = rangeValue?.from;
397
+ const to = rangeValue?.to;
398
+ const dates = dateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" });
399
+ let content;
400
+ if (from && to) {
401
+ const utc = (date) => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
402
+ const count = Math.round(Math.abs(utc(to) - utc(from)) / 864e5) + 1;
403
+ const [start, end] = +from <= +to ? [from, to] : [to, from];
404
+ content = t("dataEntry.dateRangePicker.summary", {
405
+ range: dates.formatRange(start, end),
406
+ days: t("dataEntry.dateRangePicker.days", { count })
407
+ });
408
+ } else if (from) content = dates.format(from);
409
+ return /* @__PURE__ */ jsx("div", { "data-slot": "date-picker-summary", "aria-live": "polite", "aria-atomic": "true", children: content ? /* @__PURE__ */ jsx(Flex, { pad: "sm", children: /* @__PURE__ */ jsx(Text, { size: "xs", tone: "muted", tabular: true, children: content }) }) : null });
410
+ };
392
411
  const calendarBounds = [
393
412
  ...minimum ? [{ before: minimum }] : [],
394
413
  ...maximum ? [{ after: maximum }] : [],
@@ -510,6 +529,7 @@ function DatePicker(props) {
510
529
  index
511
530
  )) }) : null,
512
531
  isPeriod ? renderPeriodPanel() : renderDayPanel(),
532
+ range && showSummary && !isPeriod ? renderSummary() : null,
513
533
  showTime ? /* @__PURE__ */ jsx(
514
534
  Flex,
515
535
  {
@@ -579,7 +599,7 @@ function DatePicker(props) {
579
599
  "aria-readonly": inputReadOnly || void 0,
580
600
  value: edge === "from" ? fromText : toText,
581
601
  disabled: edgeDisabled(edge),
582
- placeholder: resolvedPlaceholder,
602
+ placeholder: edge === "to" && halfRange && !isPeriod ? t("dataEntry.dateRangePicker.selectEnd") ?? resolvedPlaceholder : resolvedPlaceholder,
583
603
  inputMode: "numeric",
584
604
  autoComplete: "off",
585
605
  "aria-label": (edge === "from" ? t("dataEntry.dateRangePicker.from") : t("dataEntry.dateRangePicker.to")) ?? edge,
@@ -50,7 +50,7 @@ export type { TransferProps, TransferItemProp } from "./transfer.js";
50
50
  export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "./command.js";
51
51
  export type { CommandProps } from "./command.js";
52
52
  export { CommandPalette } from "./command-palette.js";
53
- export type { CommandPaletteGroup, CommandPaletteItem, CommandPaletteLabels, CommandPaletteProps, CommandPaletteSelectModifiers, } from "./command-palette.js";
53
+ export type { CommandPaletteGroup, CommandPaletteItem, CommandPaletteLabels, CommandPaletteProps, CommandPaletteRecent, CommandPaletteSelectModifiers, } from "./command-palette.js";
54
54
  export { PasswordInput } from "./password-input.js";
55
55
  export type { PasswordInputProps, PasswordVisibilityToggleProp } from "./password-input.js";
56
56
  export { PasswordStrength, usePasswordStrength } from "./password-strength.js";
@@ -8,6 +8,8 @@ export type PasswordStrengthLabels = {
8
8
  weak?: string;
9
9
  fair?: string;
10
10
  strong?: string;
11
+ /** The neutral line shown while nothing has been typed (gh#1247). */
12
+ empty?: string;
11
13
  /** Checklist line per rule. */
12
14
  rules?: Partial<Record<PasswordRule, string>>;
13
15
  /** Screen-reader prefix before a met rule (e.g. "Passed: "). */
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { Check, X } from "lucide-react";
3
+ import { Check, Minus, X } from "lucide-react";
4
4
  import { useTranslation } from "../../i18n/use-translation.js";
5
5
  const DEFAULT_PASSWORD_RULES = ["length", "upper", "lower", "number", "symbol"];
6
6
  function usePasswordStrength(value, rules = [...DEFAULT_PASSWORD_RULES], minLength = 8) {
@@ -36,12 +36,13 @@ function PasswordStrength({
36
36
  const { t } = useTranslation();
37
37
  const normalizedRules = [...new Set(rules)];
38
38
  const { score, checks } = usePasswordStrength(value, normalizedRules, minLength);
39
- const tone = scoreTone(score);
39
+ const empty = value.trim() === "";
40
+ const tone = empty ? void 0 : scoreTone(score);
40
41
  const segments = Array.from({ length: 5 });
41
42
  const key = scoreKey(score);
42
- const strength = labels[key] ?? t(`dataEntry.passwordStrength.${key}`);
43
+ const strength = empty ? labels.empty ?? t("dataEntry.passwordStrength.empty") : labels[key] ?? t(`dataEntry.passwordStrength.${key}`);
43
44
  const ruleLabel = (rule) => labels.rules?.[rule] ?? t(`dataEntry.passwordStrength.rules.${rule}`, { n: String(minLength) });
44
- const announcement = labels.announcement ? labels.announcement(strength) : t("dataEntry.passwordStrength.announcement", { strength });
45
+ const announcement = empty ? "" : labels.announcement ? labels.announcement(strength) : t("dataEntry.passwordStrength.announcement", { strength });
45
46
  return /* @__PURE__ */ jsxs("div", { className: "ui-password-strength", children: [
46
47
  /* @__PURE__ */ jsx(
47
48
  "div",
@@ -50,7 +51,7 @@ function PasswordStrength({
50
51
  role: "img",
51
52
  "aria-label": t("dataEntry.passwordStrength.meter", { score: String(score) }),
52
53
  children: segments.map((_, index) => {
53
- const filled = index < score + 1;
54
+ const filled = !empty && index < score + 1;
54
55
  return /* @__PURE__ */ jsx(
55
56
  "span",
56
57
  {
@@ -75,9 +76,9 @@ function PasswordStrength({
75
76
  "li",
76
77
  {
77
78
  className: "ui-password-strength-checklist-item",
78
- "data-state": checks[rule] ? "passed" : "failed",
79
+ "data-state": empty ? "pending" : checks[rule] ? "passed" : "failed",
79
80
  children: [
80
- checks[rule] ? /* @__PURE__ */ jsx(Check, { "aria-hidden": "true" }) : /* @__PURE__ */ jsx(X, { "aria-hidden": "true" }),
81
+ empty ? /* @__PURE__ */ jsx(Minus, { "aria-hidden": "true" }) : checks[rule] ? /* @__PURE__ */ jsx(Check, { "aria-hidden": "true" }) : /* @__PURE__ */ jsx(X, { "aria-hidden": "true" }),
81
82
  /* @__PURE__ */ jsx("span", { className: "sr-only", children: checks[rule] ? labels.passed ?? t("dataEntry.passwordStrength.passed") : labels.failed ?? t("dataEntry.passwordStrength.failed") }),
82
83
  /* @__PURE__ */ jsx("span", { children: ruleLabel(rule) })
83
84
  ]
@@ -1,4 +1,4 @@
1
1
  import * as React from "react";
2
2
  import type { SearchInputProp } from "../../props/components/data-entry.prop.js";
3
3
  export type SearchInputProps = SearchInputProp;
4
- export declare function SearchInput({ value: controlledValue, defaultValue, placeholder, debounce, onValueChange, onSearch, label, ariaLabel, className, inputClassName, id, disabled, status, variant, ...ariaProps }: SearchInputProps): React.JSX.Element;
4
+ export declare function SearchInput({ value: controlledValue, defaultValue, placeholder, debounce, onValueChange, onSearch, label, ariaLabel, className, inputClassName, id, disabled, status, variant, shortcut, ...ariaProps }: SearchInputProps): React.JSX.Element;
@@ -8,6 +8,8 @@ import { Label } from "../data-entry/label.js";
8
8
  import { cn } from "../../lib/utils.js";
9
9
  import { resolveFieldA11y } from "../../lib/field-a11y.js";
10
10
  import { useDebouncedValue } from "../../lib/hooks.js";
11
+ import { isImeComposing } from "../../lib/ime.js";
12
+ import { parseShortcut, shortcutHint, shortcutMatches } from "../../lib/shortcut.js";
11
13
  function SearchInput({
12
14
  value: controlledValue,
13
15
  defaultValue = "",
@@ -23,9 +25,12 @@ function SearchInput({
23
25
  disabled = false,
24
26
  status,
25
27
  variant,
28
+ shortcut = false,
26
29
  ...ariaProps
27
30
  }) {
28
31
  const { t } = useTranslation();
32
+ const inputRef = React.useRef(null);
33
+ const shortcutSpec = React.useMemo(() => parseShortcut(shortcut), [shortcut]);
29
34
  const isControlled = controlledValue !== void 0;
30
35
  const [internal, setInternal] = React.useState(defaultValue);
31
36
  const value = isControlled ? controlledValue : internal;
@@ -48,6 +53,17 @@ function SearchInput({
48
53
  lastSearch.current = debounced;
49
54
  onSearchRef.current?.(debounced);
50
55
  }, [debounced]);
56
+ React.useEffect(() => {
57
+ if (!shortcutSpec || disabled) return;
58
+ const handleKeyDown = (event) => {
59
+ if (isImeComposing(event) || !shortcutMatches(shortcutSpec, event)) return;
60
+ event.preventDefault();
61
+ inputRef.current?.focus();
62
+ };
63
+ document.addEventListener("keydown", handleKeyDown);
64
+ return () => document.removeEventListener("keydown", handleKeyDown);
65
+ }, [shortcutSpec, disabled]);
66
+ const showShortcut = Boolean(shortcutSpec && !value && !disabled);
51
67
  const setValue = (v) => {
52
68
  if (!isControlled) setInternal(v);
53
69
  onValueChange?.(v);
@@ -59,6 +75,7 @@ function SearchInput({
59
75
  /* @__PURE__ */ jsx(
60
76
  Input,
61
77
  {
78
+ ref: inputRef,
62
79
  id: inputId,
63
80
  type: "text",
64
81
  role: "searchbox",
@@ -78,7 +95,9 @@ function SearchInput({
78
95
  variant,
79
96
  ...fieldA11y,
80
97
  className: cn(
81
- "ui-search-input-control !ps-[var(--search-input-start-padding)] !pe-[var(--search-input-end-padding)]",
98
+ "ui-search-input-control !ps-[var(--search-input-start-padding)]",
99
+ // gh#1250 — while the key cap shows, the text stops before it instead of running under it.
100
+ showShortcut ? "!pe-[calc(var(--search-input-edge-inset)+6ch)]" : "!pe-[var(--search-input-end-padding)]",
82
101
  inputClassName
83
102
  ),
84
103
  disabled
@@ -95,7 +114,8 @@ function SearchInput({
95
114
  className: "ui-search-input-clear",
96
115
  children: /* @__PURE__ */ jsx(X, {})
97
116
  }
98
- )
117
+ ),
118
+ showShortcut && shortcutSpec ? /* @__PURE__ */ jsx("kbd", { className: "kbd ui-search-input-shortcut", "aria-hidden": "true", children: shortcutHint(shortcutSpec) }) : null
99
119
  ] })
100
120
  ] });
101
121
  }