@astryxdesign/core 0.7.1-canary.f2beffa → 0.7.1-canary.fb02ee9

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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * @file InputClearButton.tsx
3
- * @input Uses React, Button, and Icon
4
- * @output Exports the public InputClearButton and an internal popup-aware variant,
5
- * both with a contextual tooltip matching their required label.
3
+ * @input Uses React, Button, Icon, and the i18n translator
4
+ * @output Exports the public InputClearButton and an internal popup-aware variant.
5
+ * Both show the localized generic "Clear" tooltip and keep the caller's
6
+ * contextual label as the accessible name.
6
7
  * @position Shared primitive. Every input that renders a clear affordance —
7
8
  * TextInput, NumberInput, TimeInput, DateInput, DateTimeInput,
8
9
  * DateRangeInput, Selector, MultiSelector, Typeahead, Tokenizer, FileInput —
@@ -13,7 +14,10 @@
13
14
  import type { ReactNode } from 'react';
14
15
  import * as stylex from '@stylexjs/stylex';
15
16
  export interface InputClearButtonProps {
16
- /** Contextual accessible name for the button, such as "Clear Search". */
17
+ /**
18
+ * Contextual accessible name for the button, such as "Clear Search". The
19
+ * visible tooltip is always the localized generic "Clear".
20
+ */
17
21
  label: string;
18
22
  onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
19
23
  xstyle?: stylex.StyleXStyles;
@@ -1 +1 @@
1
- {"version":3,"file":"InputClearButton.d.ts","sourceRoot":"","sources":["../../src/Field/InputClearButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AACrC,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAkD3C,MAAM,WAAW,qBAAqB;IACpC,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAC;IAC1D,MAAM,CAAC,EAAE,MAAM,CAAC,YAAY,CAAC;IAC7B,iFAAiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,UAAU,6BAA8B,SAAQ,qBAAqB;IACnE,aAAa,EAAE,KAAK,CAAC,mBAAmB,CAAC,WAAW,CAAC,CAAC;IACtD,cAAc,EAAE,KAAK,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;CACtD;AAiDD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,SAAS,CAExE;yBAFe,gBAAgB;;;AAIhC,4EAA4E;AAC5E,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,6BAA6B,GACnC,SAAS,CAEX"}
1
+ {"version":3,"file":"InputClearButton.d.ts","sourceRoot":"","sources":["../../src/Field/InputClearButton.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,OAAO,CAAC;AACrC,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AAmD3C,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAC;IAC1D,MAAM,CAAC,EAAE,MAAM,CAAC,YAAY,CAAC;IAC7B,iFAAiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,UAAU,6BAA8B,SAAQ,qBAAqB;IACnE,aAAa,EAAE,KAAK,CAAC,mBAAmB,CAAC,WAAW,CAAC,CAAC;IACtD,cAAc,EAAE,KAAK,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;CACtD;AAsDD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,SAAS,CAMxE;yBANe,gBAAgB;;;AAQhC,4EAA4E;AAC5E,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,6BAA6B,GACnC,SAAS,CAMX"}
@@ -4,9 +4,10 @@
4
4
 
5
5
  /**
6
6
  * @file InputClearButton.tsx
7
- * @input Uses React, Button, and Icon
8
- * @output Exports the public InputClearButton and an internal popup-aware variant,
9
- * both with a contextual tooltip matching their required label.
7
+ * @input Uses React, Button, Icon, and the i18n translator
8
+ * @output Exports the public InputClearButton and an internal popup-aware variant.
9
+ * Both show the localized generic "Clear" tooltip and keep the caller's
10
+ * contextual label as the accessible name.
10
11
  * @position Shared primitive. Every input that renders a clear affordance —
11
12
  * TextInput, NumberInput, TimeInput, DateInput, DateTimeInput,
12
13
  * DateRangeInput, Selector, MultiSelector, Typeahead, Tokenizer, FileInput —
@@ -17,6 +18,7 @@
17
18
  import * as stylex from '@stylexjs/stylex';
18
19
  import { Button } from "../Button/index.js";
19
20
  import { Icon } from "../Icon/index.js";
21
+ import { useTranslator } from "../i18n/index.js";
20
22
  import { themeProps } from "../utils/themeProps.js";
21
23
  import { jsx as _jsx } from "react/jsx-runtime";
22
24
  const styles = {
@@ -34,6 +36,7 @@ const styles = {
34
36
  };
35
37
  function renderInputClearButton({
36
38
  label,
39
+ tooltip,
37
40
  onClick,
38
41
  onPointerDown,
39
42
  onClickCapture,
@@ -49,8 +52,13 @@ function renderInputClearButton({
49
52
  return /*#__PURE__*/_jsx(Button, {
50
53
  variant: "ghost",
51
54
  size: "sm",
52
- label: label,
53
- tooltip: label,
55
+ label: label
56
+ // The field's label already sits beside the glyph, so the tooltip names
57
+ // only the action; repeating a long label reads as awkward copy. The
58
+ // contextual label stays the accessible name, which still contains
59
+ // the visible word (WCAG 2.5.3, label in name).
60
+ ,
61
+ tooltip: tooltip,
54
62
  className: buttonTargetClassName,
55
63
  icon: /*#__PURE__*/_jsx(Icon, {
56
64
  icon: "close",
@@ -70,11 +78,19 @@ function renderInputClearButton({
70
78
  });
71
79
  }
72
80
  export function InputClearButton(props) {
73
- return renderInputClearButton(props);
81
+ const t = useTranslator();
82
+ return renderInputClearButton({
83
+ ...props,
84
+ tooltip: t('@astryx.inputClearButton.tooltip')
85
+ });
74
86
  }
75
87
 
76
88
  /** Internal variant used while popup-aware clear behavior is rolled out. */
77
89
  export function InternalInputClearButton(props) {
78
- return renderInputClearButton(props);
90
+ const t = useTranslator();
91
+ return renderInputClearButton({
92
+ ...props,
93
+ tooltip: t('@astryx.inputClearButton.tooltip')
94
+ });
79
95
  }
80
96
  InputClearButton.displayName = 'InputClearButton';
@@ -1 +1 @@
1
- {"version":3,"file":"en.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/en.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAkcpD,CAAC;AAEF,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"en.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/en.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmcpD,CAAC;AAEF,eAAe,cAAc,CAAC"}
@@ -359,6 +359,7 @@ const runtimeCatalog = {
359
359
  '@astryx.tableTree.expandAllRows': 'Expand all rows',
360
360
  '@astryx.textArea.charactersRemaining': '{count, number} {count, plural, one {character} other {characters}} remaining',
361
361
  '@astryx.textArea.charactersOverLimit': '{count, number} {count, plural, one {character} other {characters}} over the limit',
362
+ '@astryx.inputClearButton.tooltip': 'Clear',
362
363
  '@astryx.textInput.clearLabel': 'Clear {label}',
363
364
  '@astryx.thumbnail.remove': 'Remove {accessibleName}',
364
365
  '@astryx.thumbnail.open': 'Open {accessibleName}',
@@ -1 +1 @@
1
- {"version":3,"file":"pseudo.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/pseudo.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA2cpD,CAAC;AAEF,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"pseudo.generated.d.ts","sourceRoot":"","sources":["../../../src/i18n/generated-locales/pseudo.generated.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,QAAA,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA4cpD,CAAC;AAEF,eAAe,cAAc,CAAC"}
@@ -359,6 +359,7 @@ const runtimeCatalog = {
359
359
  '@astryx.tableTree.expandAllRows': '⟦Éẋþàñð àłł řóŵš⟧',
360
360
  '@astryx.textArea.charactersRemaining': '⟦{count, number} {count, plural, one {character} other {characters}} řéɱàíñíñĝ⟧',
361
361
  '@astryx.textArea.charactersOverLimit': '⟦{count, number} {count, plural, one {character} other {characters}} óṽéř ţĥé łíɱíţ⟧',
362
+ '@astryx.inputClearButton.tooltip': '⟦Çłéàř⟧',
362
363
  '@astryx.textInput.clearLabel': '⟦Çłéàř {label}⟧',
363
364
  '@astryx.thumbnail.remove': '⟦Řéɱóṽé {accessibleName}⟧',
364
365
  '@astryx.thumbnail.open': '⟦Óþéñ {accessibleName}⟧',
package/locales/en.json CHANGED
@@ -1407,6 +1407,10 @@
1407
1407
  "defaultMessage": "{count, number} {count, plural, one {character} other {characters}} over the limit",
1408
1408
  "description": "Screen-reader-only assertive announcement when the user has typed past a TextArea's maxLength. `{count}` is how many characters over — example: `3 characters over the limit`."
1409
1409
  },
1410
+ "@astryx.inputClearButton.tooltip": {
1411
+ "defaultMessage": "Clear",
1412
+ "description": "Tooltip shown on hover or keyboard focus over the X button inside an input that clears its value. Verb, imperative. Shown alone beside the field, so it names only the action; the screen-reader label names the field separately."
1413
+ },
1410
1414
  "@astryx.textInput.clearLabel": {
1411
1415
  "defaultMessage": "Clear {label}",
1412
1416
  "description": "Screen-reader-only label on the X that clears the value from a TextInput. Example: `Clear Email`, `Clear Name`."
@@ -1055,6 +1055,9 @@
1055
1055
  "@astryx.textArea.charactersOverLimit": {
1056
1056
  "defaultMessage": "⟦{count, number} {count, plural, one {character} other {characters}} óṽéř ţĥé łíɱíţ⟧"
1057
1057
  },
1058
+ "@astryx.inputClearButton.tooltip": {
1059
+ "defaultMessage": "⟦Çłéàř⟧"
1060
+ },
1058
1061
  "@astryx.textInput.clearLabel": {
1059
1062
  "defaultMessage": "⟦Çłéàř {label}⟧"
1060
1063
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.1-canary.f2beffa",
3
+ "version": "0.7.1-canary.fb02ee9",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -787,8 +787,8 @@
787
787
  "react-dom": ">=19.0.0"
788
788
  },
789
789
  "devDependencies": {
790
- "@astryxdesign/a11y-spec": "0.7.1-canary.f2beffa",
791
- "@astryxdesign/cli": "0.7.1-canary.f2beffa",
790
+ "@astryxdesign/a11y-spec": "0.7.1-canary.fb02ee9",
791
+ "@astryxdesign/cli": "0.7.1-canary.fb02ee9",
792
792
  "@babel/cli": "^7.29.7",
793
793
  "@babel/core": "^7.29.7",
794
794
  "@babel/preset-react": "^7.29.7",
@@ -133,6 +133,7 @@ Consumer migration instructions belong in consumer docs and release notes.
133
133
  - **AR1 — Child semantics remain primary.** Group coordination MUST preserve each Collapsible button's accessible name, `aria-expanded`, `aria-controls`, controlled region, and keyboard activation.
134
134
  - **AR2 — Presentation adds no competing interaction.** The divider wrapper MUST NOT add a role, focus target, accessible name, or keyboard behavior.
135
135
  - **AR3 — Direction stays with Collapsible.** Group chevron defaults MUST preserve `component:Collapsible/AR1–AR3`, including the inward leading cue under LTR and RTL.
136
+ - **AR4 — The group adopts the APG Accordion pattern's required parts** (`spec:AST-020/FR15`): each direct item's header is a `button` whose accessible name is the item's title, with `aria-expanded` reflecting its panel, `aria-controls` naming the panel it reveals, and `aria-disabled` when it cannot be toggled; every enabled header sits in the page Tab order and Enter or Space toggles its panel, in `single` mode collapsing the sibling that was open; a disabled header carries `aria-disabled`, drops out of the Tab order, and cannot be toggled. Not adopted: a heading element around each header (this record requires none; the caller's document structure supplies headings), the optional `region` role on panels, and arrow-key or Home/End movement between headers — the group is a set of independent disclosures, not one composite control.
136
137
 
137
138
  ## Design relationships
138
139
 
@@ -171,17 +172,25 @@ The Group container is CollapsibleGroup's conditional aggregate wrapper. The oth
171
172
 
172
173
  ## Verification map
173
174
 
174
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
175
- | ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
176
- | FR1–FR4, ORD1 | `CollapsibleGroup.test.tsx`; `useCollapsible.test.tsx` | single/multiple; controlled/uncontrolled; valued/unvalued | A sibling stays open in single mode, closes in multiple mode, controlled state mutates internally, or an unvalued item loses local state. | `audit:CollapsibleGroup/behavior` |
177
- | FR5–FR6, ORD2–ORD3 | `CollapsibleGroup.test.tsx`; `Collapsible.test.tsx`; `themingTargets.test.ts` | plain/divided; each density; group/item/nested position | Wrapper/target reachability changes, defaults stop reaching direct items, or presentation leaks into nested content. | `audit:CollapsibleGroup/theming` |
178
- | AR1–AR3 | DOM and Chromium accessibility suites; component-owned Storybook fixture; RTL audit | open/closed; single/multiple; leading/trailing; LTR/RTL | Disclosure semantics diverge from state, the wrapper becomes interactive, or leading direction stops mirroring. | `audit:CollapsibleGroup/accessibility` |
179
- | PR1–PR2, FR7 | source review plus context and interaction tests | unchanged and changed state/presentation inputs | An Effect/resource appears, unchanged payload broadcasts a new identity, or changed group behavior fails to reach consumers. | `audit:CollapsibleGroup/code-health` |
180
- | Consumer documentation | CLI component projection, docs checks, and `CollapsibleGroup.stories.tsx` | every public concept and representative composition | A released prop/default is undiscoverable or the owned browser route loses representative state coverage. | `audit:CollapsibleGroup/docs` |
175
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
176
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
177
+ | FR1–FR4, ORD1 | `CollapsibleGroup.test.tsx`; `useCollapsible.test.tsx` | single/multiple; controlled/uncontrolled; valued/unvalued | A sibling stays open in single mode, closes in multiple mode, controlled state mutates internally, or an unvalued item loses local state. | `audit:CollapsibleGroup/behavior` |
178
+ | FR5–FR6, ORD2–ORD3 | `CollapsibleGroup.test.tsx`; `Collapsible.test.tsx`; `themingTargets.test.ts` | plain/divided; each density; group/item/nested position | Wrapper/target reachability changes, defaults stop reaching direct items, or presentation leaks into nested content. | `audit:CollapsibleGroup/theming` |
179
+ | AR4 | `Collapsible.test.tsx` (disabled trigger: `aria-disabled`, out of the Tab order, no toggle); `CollapsibleGroup.test.tsx` (single/multiple coordination, `aria-expanded`, Enter activation); `Collapsible.a11y.test.tsx` and `Collapsible.a11y.chromium.spec.ts` (disclosure semantics, Space, Tab reachability for a lone item); grouped Tab order, Space in a group, and no arrow-key movement under the Accordion contract (`internal/a11y-spec`, pending) | single/multiple; open/closed; a disabled item, open or closed; Tab order; Enter/Space round trip | A header loses its button role, name, `aria-expanded`, or `aria-controls`; Enter or Space stops toggling; arrow keys move focus between headers | `audit:CollapsibleGroup/accessibility` |
180
+ | AR1–AR3 | DOM and Chromium accessibility suites; component-owned Storybook fixture; RTL audit | open/closed; single/multiple; leading/trailing; LTR/RTL | Disclosure semantics diverge from state, the wrapper becomes interactive, or leading direction stops mirroring. | `audit:CollapsibleGroup/accessibility` |
181
+ | PR1–PR2, FR7 | source review plus context and interaction tests | unchanged and changed state/presentation inputs | An Effect/resource appears, unchanged payload broadcasts a new identity, or changed group behavior fails to reach consumers. | `audit:CollapsibleGroup/code-health` |
182
+ | Consumer documentation | CLI component projection, docs checks, and `CollapsibleGroup.stories.tsx` | every public concept and representative composition | A released prop/default is undiscoverable or the owned browser route loses representative state coverage. | `audit:CollapsibleGroup/docs` |
181
183
 
182
184
  ## Decision log
183
185
 
184
- None. This audit records shipped behavior and delegates chevron representation to the current Collapsible contract; it makes no new product decision.
186
+ ### DEC-1 — Adopt Accordion's required parts; keep the headers independent
187
+
188
+ **Reference:** `component:CollapsibleGroup/DEC-1`
189
+ **Decider:** `cixzhang`, `2026-10-13`
190
+
191
+ The group's headers already play the accordion header's role, so they carry its name, state, relationship, and Enter/Space activation (AR4). They stay in the page Tab order without arrow-key or Home/End movement because the group coordinates independent disclosures rather than acting as one composite control; a heading wrapper is left to the caller's document structure.
192
+
193
+ Rejected: arrow-key movement between headers; a required heading element around each header.
185
194
 
186
195
  ## Open questions
187
196
 
@@ -25,6 +25,7 @@ import {
25
25
  type InputClearButtonProps,
26
26
  } from './InputClearButton';
27
27
  import {Icon, registerIcons, resetIcons} from '../Icon';
28
+ import {InternationalizationProvider} from '../i18n';
28
29
  import {defineTheme} from '../theme/defineTheme';
29
30
  import {generateThemeCSS} from '../theme/generateThemeRules';
30
31
  import {declaredValue} from '../__tests__/stylexDeclarations';
@@ -95,13 +96,23 @@ describe('InputClearButton public props', () => {
95
96
  });
96
97
 
97
98
  describe('InputClearButton', () => {
98
- it('uses the contextual label for the tooltip and accessible name', async () => {
99
+ it('shows a generic "Clear" tooltip while the accessible name keeps the field label', async () => {
100
+ // A long field label repeated in the tooltip reads as awkward copy
101
+ // ("Clear Assigned engineering partner"); the field's own label already
102
+ // sits beside the glyph. The contextual name stays for assistive tech.
99
103
  const showPopover = vi.spyOn(HTMLElement.prototype, 'showPopover');
100
- render(<InputClearButton label="Clear Search" onClick={() => {}} />);
104
+ render(
105
+ <InputClearButton
106
+ label="Clear Assigned engineering partner"
107
+ onClick={() => {}}
108
+ />,
109
+ );
101
110
 
102
- const button = screen.getByRole('button', {name: 'Clear Search'});
111
+ const button = screen.getByRole('button', {
112
+ name: 'Clear Assigned engineering partner',
113
+ });
103
114
  const tooltip = screen.getByRole('tooltip', {hidden: true});
104
- expect(tooltip).toHaveTextContent(/^Clear Search$/);
115
+ expect(tooltip).toHaveTextContent(/^Clear$/);
105
116
  expect(button.getAttribute('aria-describedby')).toBe(tooltip.id);
106
117
 
107
118
  fireEvent.mouseEnter(button);
@@ -109,6 +120,20 @@ describe('InputClearButton', () => {
109
120
  showPopover.mockRestore();
110
121
  });
111
122
 
123
+ it('localizes the tooltip through the catalog', () => {
124
+ render(
125
+ <InternationalizationProvider
126
+ locale="en"
127
+ overrides={{en: {'@astryx.inputClearButton.tooltip': 'Leeren'}}}>
128
+ <InputClearButton label="Suche leeren" onClick={() => {}} />
129
+ </InternationalizationProvider>,
130
+ );
131
+ expect(screen.getByRole('button', {name: 'Suche leeren'})).toBeTruthy();
132
+ expect(screen.getByRole('tooltip', {hidden: true})).toHaveTextContent(
133
+ /^Leeren$/,
134
+ );
135
+ });
136
+
112
137
  it('renders a real button with the given accessible label', () => {
113
138
  render(<InputClearButton label="Clear" onClick={() => {}} />);
114
139
  const button = screen.getByRole('button', {name: 'Clear'});
@@ -4,9 +4,10 @@
4
4
 
5
5
  /**
6
6
  * @file InputClearButton.tsx
7
- * @input Uses React, Button, and Icon
8
- * @output Exports the public InputClearButton and an internal popup-aware variant,
9
- * both with a contextual tooltip matching their required label.
7
+ * @input Uses React, Button, Icon, and the i18n translator
8
+ * @output Exports the public InputClearButton and an internal popup-aware variant.
9
+ * Both show the localized generic "Clear" tooltip and keep the caller's
10
+ * contextual label as the accessible name.
10
11
  * @position Shared primitive. Every input that renders a clear affordance —
11
12
  * TextInput, NumberInput, TimeInput, DateInput, DateTimeInput,
12
13
  * DateRangeInput, Selector, MultiSelector, Typeahead, Tokenizer, FileInput —
@@ -19,6 +20,7 @@ import type {ReactNode} from 'react';
19
20
  import * as stylex from '@stylexjs/stylex';
20
21
  import {Button} from '../Button';
21
22
  import {Icon} from '../Icon';
23
+ import {useTranslator} from '../i18n';
22
24
  import {themeProps} from '../utils/themeProps';
23
25
 
24
26
  const styles = stylex.create({
@@ -67,7 +69,10 @@ const styles = stylex.create({
67
69
  });
68
70
 
69
71
  export interface InputClearButtonProps {
70
- /** Contextual accessible name for the button, such as "Clear Search". */
72
+ /**
73
+ * Contextual accessible name for the button, such as "Clear Search". The
74
+ * visible tooltip is always the localized generic "Clear".
75
+ */
71
76
  label: string;
72
77
  onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
73
78
  xstyle?: stylex.StyleXStyles;
@@ -83,10 +88,11 @@ interface InternalInputClearButtonProps extends InputClearButtonProps {
83
88
  type InputClearButtonRenderProps = InputClearButtonProps &
84
89
  Partial<
85
90
  Pick<InternalInputClearButtonProps, 'onPointerDown' | 'onClickCapture'>
86
- >;
91
+ > & {tooltip: string};
87
92
 
88
93
  function renderInputClearButton({
89
94
  label,
95
+ tooltip,
90
96
  onClick,
91
97
  onPointerDown,
92
98
  onClickCapture,
@@ -100,7 +106,11 @@ function renderInputClearButton({
100
106
  variant="ghost"
101
107
  size="sm"
102
108
  label={label}
103
- tooltip={label}
109
+ // The field's label already sits beside the glyph, so the tooltip names
110
+ // only the action; repeating a long label reads as awkward copy. The
111
+ // contextual label stays the accessible name, which still contains
112
+ // the visible word (WCAG 2.5.3, label in name).
113
+ tooltip={tooltip}
104
114
  className={buttonTargetClassName}
105
115
  icon={
106
116
  <Icon
@@ -128,14 +138,22 @@ function renderInputClearButton({
128
138
  }
129
139
 
130
140
  export function InputClearButton(props: InputClearButtonProps): ReactNode {
131
- return renderInputClearButton(props);
141
+ const t = useTranslator();
142
+ return renderInputClearButton({
143
+ ...props,
144
+ tooltip: t('@astryx.inputClearButton.tooltip'),
145
+ });
132
146
  }
133
147
 
134
148
  /** Internal variant used while popup-aware clear behavior is rolled out. */
135
149
  export function InternalInputClearButton(
136
150
  props: InternalInputClearButtonProps,
137
151
  ): ReactNode {
138
- return renderInputClearButton(props);
152
+ const t = useTranslator();
153
+ return renderInputClearButton({
154
+ ...props,
155
+ tooltip: t('@astryx.inputClearButton.tooltip'),
156
+ });
139
157
  }
140
158
 
141
159
  InputClearButton.displayName = 'InputClearButton';
@@ -44,36 +44,6 @@ layout, targets, or public API.
44
44
 
45
45
  Consumer migration instructions belong in consumer docs and release notes.
46
46
 
47
- ### DEP-0010 — Deprecate the fixed meaning of numeric `Grid` `columns`
48
-
49
- | Field | Value |
50
- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
- | id | `DEP-0010` |
52
- | cleanup | `CLN-0019` |
53
- | package | `@astryxdesign/core` |
54
- | surface | `Grid` `columns={N}`, which renders exactly N equal tracks (`repeat(N, 1fr)`) |
55
- | old contract | A numeric `columns` value is an explicit equal-width track count at every width |
56
- | replacement | `columns={{count: N, isFixed: true}}` keeps exactly N tracks |
57
- | direct authority | `component:Grid`, with `family:layout-primitives` representative matrix row "Grid / fixed columns". Owners: `cixzhang` (`component:Grid`); `cixzhang`, `imdreamrunner` (`family:layout-primitives`) |
58
- | warning | Consumer docs and the release notes name `DEP-0010` and the fixed shape. No runtime warning (`spec:AST-017` FR29) |
59
- | migration | Mechanical where the fixed count is wanted: rewrite `columns={N}` to `columns={{count: N, isFixed: true}}`. Keeping the number accepts the new reflow |
60
- | codemod | None yet. A preserving codemod is mechanical, but it would opt every caller out of the change it exists to deliver |
61
- | downstream | In-repo fixed rows such as week rows use the fixed shape |
62
- | transition | no prior transition |
63
- | state | `proposed` |
64
- | open decision | This record is draft and lists new responsive behavior as a non-goal; the family matrix pins numeric columns as fixed. Both need an owner ruling |
65
- | target plan | `CLN-0019` in the next minor release |
66
-
67
- ### CLN-0019 — Numeric `columns` means at most N columns
68
-
69
- | Field | Value |
70
- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
71
- | id | `CLN-0019` |
72
- | lifecycle | `DEP-0010` |
73
- | delta | A numeric `columns` value renders up to N columns of at least 12rem and fewer when the grid is narrower; a `GridSpan` in such a grid takes the full row once the grid has fewer columns than it spans |
74
- | rollback | Restore `repeat(N, 1fr)` for numeric `columns` from the final-patch baseline |
75
- | state | `pending` — lands in a declared minor with its `[breaking]` Changeset |
76
-
77
47
  ## Ownership boundary
78
48
 
79
49
  **Owns**
@@ -110,6 +80,43 @@ in the canonical `Grid.doc.mjs` and the subordinate `GridSpan.doc.mjs`;
110
80
  - A Spanning item may span columns, rows, both, or neither while retaining the
111
81
  same target.
112
82
 
83
+ ### Responsive contract
84
+
85
+ The `columns` prop decides how many column tracks Grid lays out and whether
86
+ that count changes with the container's width. Other inputs change the result
87
+ only by changing what `columns` has to fit: `width` and `maxWidth` set the
88
+ container width a reflowing shape measures against; an `xstyle`, `className`,
89
+ or `style` that sets the track template (including under a media query)
90
+ replaces the computed one; and a numeric GridSpan wider than the grid's tracks
91
+ makes the browser add implicit tracks to the whole grid, which later items flow
92
+ into. No other prop changes the count.
93
+
94
+ | `columns` | Behavior | Reflows |
95
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
96
+ | `N` (number) | Exactly N tracks that share the row equally at every container width; a child wider than its share widens its track and can push the grid past a narrow container. | never |
97
+ | `{minWidth}` | As many tracks as fit while each stays at least `minWidth` px wide. A container narrower than `minWidth` keeps one `minWidth`-wide track that overflows it. Empty tracks keep their width. | yes |
98
+ | `{minWidth, repeat: 'fit'}` | As above, and empty tracks collapse so the present items stretch across the row. | yes |
99
+ | `{minWidth, max}` | Up to `max` tracks, each at least `minWidth` px and never narrower than an equal share of the row; fewer tracks as the container narrows, down to one track that shrinks to the container rather than overflowing it. `repeat: 'fit'` applies the same way. | yes |
100
+ | omitted, `0`, or negative | One track. | never |
101
+
102
+ - `gap` and `columnGap` count toward the width a reflowing shape has to fit, so
103
+ the full count is reached only when the container has room for the tracks and
104
+ the gaps between them. They never change the count of a numeric `columns`.
105
+ - `height`, `minHeight`, `rowHeight`, `align`, and `justify` size or place the
106
+ container and its items and do not change the column count.
107
+ - GridSpan spans `k` tracks, or the whole row for `'full'`. `'full'` never
108
+ adds tracks. A numeric span wider than the grid's current tracks makes the
109
+ browser add implicit tracks to the grid for the remainder; they persist for
110
+ every row, items placed before and after the span flow into them, and once
111
+ they exist `'full'` covers only the explicit tracks. In a reflowing grid keep
112
+ spans within the smallest count the grid can reach or use `'full'`.
113
+ - For columns that reflow on small screens, use `{minWidth, max}`: `max` is
114
+ what lets the last track shrink to a narrow container, so `{minWidth}` alone
115
+ still overflows a phone narrower than `minWidth`. A numeric `columns` never
116
+ reflows, so a four-up numeric row on a phone renders four narrow tracks, or
117
+ overflows when a child cannot shrink to its share; that is the fixed
118
+ contract, not a defect.
119
+
113
120
  ### Representative states
114
121
 
115
122
  | State | Required invariant | Allowed variation |
@@ -178,8 +185,19 @@ relationship.
178
185
 
179
186
  ## Decision log
180
187
 
181
- None. This draft records current facts and introduces no component-local design,
182
- layout, accessibility, or API decision.
188
+ ### DEC-1 — Numeric `columns` stays fixed; reflow uses the existing object form
189
+
190
+ **Decider:** `cixzhang`, `2026-10-11`
191
+
192
+ `columns={N}` means exactly N equal tracks at every width, and a grid that
193
+ reflows on small screens uses `{minWidth, max?, repeat?}`. Two shapes, one
194
+ responsibility each: the number is the fixed contract, the object form is the
195
+ responsive one, and the record and the docs say so where a caller first meets
196
+ `columns`.
197
+
198
+ Rejected: redefining the number as "at most N" (breaks every released numeric
199
+ grid and needs an opt-out shape); a `{count, isFixed}` shape (duplicates the
200
+ number and the `max` cap).
183
201
 
184
202
  ## Open questions
185
203
 
@@ -19,8 +19,10 @@
19
19
  * The timed region inside each process is the spec's protocol, unsmoothed:
20
20
  * ten untimed warmups, then one median of nine alternating paired rounds,
21
21
  * for a stable plugin list and a recreated equivalent one, against the
22
- * omitted baseline. One over-budget A/B triggers two fresh-process attempts;
23
- * the unchanged budget fails only when at least two of the three exceed it.
22
+ * omitted baseline. A first attempt within budget on both paths certifies
23
+ * them. Otherwise up to nine fresh-process attempts decide each path on its
24
+ * own: a path fails the unchanged budget when a majority of the nine (five)
25
+ * exceed it, and collection stops as soon as both paths are decided.
24
26
  */
25
27
 
26
28
  import {execFileSync} from 'node:child_process';
@@ -166,18 +168,64 @@ function logBenchmarkResult(result: BenchmarkResult, attempt: number): void {
166
168
  );
167
169
  }
168
170
 
171
+ /** The two paths FR23 measures; each is held to the budget on its own. */
172
+ const FR23_PATHS = ['stable', 'recreated'] as const;
173
+ type FR23Path = (typeof FR23_PATHS)[number];
174
+
175
+ /**
176
+ * Fresh-process attempts that decide a path after a noisy first one. On CI a
177
+ * clean representative set measures about 1.19 at 500 sections with about
178
+ * ±0.04 of sample noise, so a single attempt lands over the budget roughly
179
+ * one time in ten; a majority of nine does so well under one time in a
180
+ * thousand, while a slowdown of 0.1 or more still exceeds it nearly always.
181
+ */
182
+ const FR23_ATTEMPTS = 9;
183
+ const FR23_MAJORITY = Math.floor(FR23_ATTEMPTS / 2) + 1;
184
+
185
+ function attemptsOverBudget(
186
+ attempts: ReadonlyArray<BenchmarkResult>,
187
+ fr23Path: FR23Path,
188
+ ): number {
189
+ return attempts.filter(result => result[fr23Path] > FR23_BUDGET).length;
190
+ }
191
+
192
+ /** Whether the remaining attempts can no longer change this path's verdict. */
193
+ function pathDecided(
194
+ attempts: ReadonlyArray<BenchmarkResult>,
195
+ fr23Path: FR23Path,
196
+ ): boolean {
197
+ const over = attemptsOverBudget(attempts, fr23Path);
198
+ return over >= FR23_MAJORITY || attempts.length - over >= FR23_MAJORITY;
199
+ }
200
+
169
201
  function collectFR23Attempts(
170
202
  sections: number,
171
203
  runAttempt: (sections: number) => BenchmarkResult = runSettledBenchmark,
172
204
  ): ReadonlyArray<BenchmarkResult> {
173
205
  const attempts = [runAttempt(sections)];
174
- if (exceedsFR23Budget(attempts[0])) {
175
- attempts.push(runAttempt(sections));
206
+ if (!exceedsFR23Budget(attempts[0])) {
207
+ return attempts;
208
+ }
209
+ while (
210
+ attempts.length < FR23_ATTEMPTS &&
211
+ !FR23_PATHS.every(fr23Path => pathDecided(attempts, fr23Path))
212
+ ) {
176
213
  attempts.push(runAttempt(sections));
177
214
  }
178
215
  return attempts;
179
216
  }
180
217
 
218
+ /** The paths whose attempts exceed the budget in a majority of nine. */
219
+ function pathsOverBudget(
220
+ attempts: ReadonlyArray<BenchmarkResult>,
221
+ ): ReadonlyArray<FR23Path> {
222
+ return FR23_PATHS.filter(fr23Path =>
223
+ attempts.length > 1
224
+ ? attemptsOverBudget(attempts, fr23Path) >= FR23_MAJORITY
225
+ : attemptsOverBudget(attempts, fr23Path) > 0,
226
+ );
227
+ }
228
+
181
229
  describe('Markdown FR23 helper overhead', () => {
182
230
  it('reports one nine-pair median without selecting the cheapest baseline', () => {
183
231
  // Paired ratios: four 1.1s, one 1.2, four 10s. The median is 1.2,
@@ -218,35 +266,104 @@ describe('Markdown FR23 helper overhead', () => {
218
266
  }
219
267
  });
220
268
 
221
- it('retries one over-budget A/B twice and uses the three-attempt majority', () => {
269
+ describe('deciding each path from fresh-process attempts', () => {
222
270
  const result = (stable: number, recreated: number): BenchmarkResult => ({
223
- sections: 200,
271
+ sections: 500,
224
272
  stable,
225
273
  recreated,
226
274
  control: 1,
227
275
  });
228
- const passFirst = [result(1.2, 1.22)];
229
- expect(collectFR23Attempts(200, () => passFirst.shift()!)).toHaveLength(1);
230
-
231
- const retryResults = [
232
- result(1.27, 1.1),
233
- result(1.2, 1.22),
234
- result(1.18, 1.19),
235
- ];
236
- const attempts = collectFR23Attempts(200, () => retryResults.shift()!);
237
- expect(attempts).toHaveLength(3);
238
- expect(attempts.filter(exceedsFR23Budget)).toHaveLength(1);
239
-
240
- const majorityResults = [
241
- result(1.27, 1.1),
242
- result(1.2, 1.26),
243
- result(1.18, 1.19),
244
- ];
245
- expect(
246
- collectFR23Attempts(200, () => majorityResults.shift()!).filter(
247
- exceedsFR23Budget,
248
- ),
249
- ).toHaveLength(2);
276
+ const run = (results: BenchmarkResult[]) => {
277
+ const queue = [...results];
278
+ let calls = 0;
279
+ const attempts = collectFR23Attempts(500, () => {
280
+ calls++;
281
+ const next = queue.shift();
282
+ if (next == null) {
283
+ throw new Error('asked for an unplanned attempt');
284
+ }
285
+ return next;
286
+ });
287
+ return {attempts, calls, failing: pathsOverBudget(attempts)};
288
+ };
289
+
290
+ it('certifies both paths from one attempt within budget', () => {
291
+ const {calls, failing} = run([result(1.2, 1.22)]);
292
+ expect(calls).toBe(1);
293
+ expect(failing).toEqual([]);
294
+ });
295
+
296
+ it('fails a single attempt over budget only if no retry ran', () => {
297
+ expect(pathsOverBudget([result(1.26, 1.2)])).toEqual(['stable']);
298
+ });
299
+
300
+ it('judges each path by its own majority, not either path per attempt', () => {
301
+ // Main run 38393555595: stable was over in attempt 1 and recreated in
302
+ // attempt 2, which the old either-path-per-attempt count read as a
303
+ // two-of-three failure although neither path was over in a majority.
304
+ const {attempts, failing} = run([
305
+ result(1.256, 1.201),
306
+ result(1.145, 1.275),
307
+ result(1.183, 1.187),
308
+ result(1.18, 1.2),
309
+ result(1.19, 1.21),
310
+ result(1.2, 1.22),
311
+ result(1.17, 1.19),
312
+ ]);
313
+ // Each path needs five attempts within budget; each had one over.
314
+ expect(attempts).toHaveLength(6);
315
+ expect(failing).toEqual([]);
316
+ });
317
+
318
+ it('stops as soon as both paths are decided', () => {
319
+ // Recreated is decided after five passes, stable after one more.
320
+ const {calls} = run([
321
+ result(1.3, 1.2),
322
+ result(1.2, 1.2),
323
+ result(1.2, 1.2),
324
+ result(1.2, 1.2),
325
+ result(1.2, 1.2),
326
+ result(1.2, 1.2),
327
+ ]);
328
+ expect(calls).toBe(6);
329
+ });
330
+
331
+ it('fails a path that is over budget in a majority of nine', () => {
332
+ const {attempts, failing} = run([
333
+ result(1.3, 1.2),
334
+ result(1.27, 1.2),
335
+ result(1.2, 1.2),
336
+ result(1.26, 1.21),
337
+ result(1.22, 1.2),
338
+ result(1.28, 1.19),
339
+ result(1.26, 1.2),
340
+ ]);
341
+ expect(attempts).toHaveLength(7);
342
+ expect(failing).toEqual(['stable']);
343
+ });
344
+
345
+ it('fails a consistent slowdown on both paths after five attempts', () => {
346
+ const {calls, failing} = run(
347
+ Array.from({length: 9}, () => result(1.4, 1.42)),
348
+ );
349
+ expect(calls).toBe(5);
350
+ expect(failing).toEqual(['stable', 'recreated']);
351
+ });
352
+
353
+ it('never runs more than nine attempts', () => {
354
+ const {calls} = run([
355
+ result(1.3, 1.3),
356
+ result(1.2, 1.2),
357
+ result(1.3, 1.3),
358
+ result(1.2, 1.2),
359
+ result(1.3, 1.3),
360
+ result(1.2, 1.2),
361
+ result(1.3, 1.3),
362
+ result(1.2, 1.2),
363
+ result(1.3, 1.3),
364
+ ]);
365
+ expect(calls).toBe(9);
366
+ });
250
367
  });
251
368
 
252
369
  it.each([200, 500])(
@@ -257,28 +374,32 @@ describe('Markdown FR23 helper overhead', () => {
257
374
  // attempt counts: a value over budget is machine noise, so that process
258
375
  // is replaced before its A/B result can certify or fail the helpers.
259
376
  //
260
- // A single over-budget A/B is also noisy at this threshold. Retry that
261
- // result twice in fresh processes and fail only when a majority (at
262
- // least two of the three independent attempts) exceeds the unchanged
263
- // 1.25 budget.
377
+ // A single over-budget A/B is also noisy at this threshold. Each path
378
+ // is then decided by a majority of up to nine fresh-process attempts
379
+ // against the unchanged 1.25 budget.
264
380
  const attempts = collectFR23Attempts(sections);
265
- if (attempts.length === 3) {
381
+ if (attempts.length > 1) {
266
382
  console.log(
267
383
  ` ${sections} sections: first A/B exceeded ${FR23_BUDGET};` +
268
- ' retried twice in fresh processes',
384
+ ` decided each path from ${attempts.length} fresh processes`,
269
385
  );
270
386
  }
271
387
  attempts.forEach((result, index) =>
272
388
  logBenchmarkResult(result, index + 1),
273
389
  );
274
- const overBudget = attempts.filter(exceedsFR23Budget).length;
390
+ const failing = pathsOverBudget(attempts);
275
391
  expect(
276
- overBudget,
277
- `${overBudget} of ${attempts.length} fresh FR23 attempts exceeded ` +
278
- `the ${FR23_BUDGET} budget`,
279
- ).toBeLessThan(2);
392
+ failing,
393
+ failing
394
+ .map(
395
+ fr23Path =>
396
+ `${fr23Path}: ${attemptsOverBudget(attempts, fr23Path)} of ` +
397
+ `${attempts.length} fresh FR23 attempts exceeded the ${FR23_BUDGET} budget`,
398
+ )
399
+ .join('; '),
400
+ ).toEqual([]);
280
401
  },
281
- 300_000,
402
+ 600_000,
282
403
  );
283
404
 
284
405
  it('fails loudly when the benchmark process cannot produce a result', () => {
@@ -386,6 +386,7 @@ const runtimeCatalog: Readonly<Record<string, string>> = {
386
386
  '{count, number} {count, plural, one {character} other {characters}} remaining',
387
387
  '@astryx.textArea.charactersOverLimit':
388
388
  '{count, number} {count, plural, one {character} other {characters}} over the limit',
389
+ '@astryx.inputClearButton.tooltip': 'Clear',
389
390
  '@astryx.textInput.clearLabel': 'Clear {label}',
390
391
  '@astryx.thumbnail.remove': 'Remove {accessibleName}',
391
392
  '@astryx.thumbnail.open': 'Open {accessibleName}',
@@ -393,6 +393,7 @@ const runtimeCatalog: Readonly<Record<string, string>> = {
393
393
  '⟦{count, number} {count, plural, one {character} other {characters}} řéɱàíñíñĝ⟧',
394
394
  '@astryx.textArea.charactersOverLimit':
395
395
  '⟦{count, number} {count, plural, one {character} other {characters}} óṽéř ţĥé łíɱíţ⟧',
396
+ '@astryx.inputClearButton.tooltip': '⟦Çłéàř⟧',
396
397
  '@astryx.textInput.clearLabel': '⟦Çłéàř {label}⟧',
397
398
  '@astryx.thumbnail.remove': '⟦Řéɱóṽé {accessibleName}⟧',
398
399
  '@astryx.thumbnail.open': '⟦Óþéñ {accessibleName}⟧',