@astryxdesign/core 0.7.1 → 0.7.2-canary.b06a093

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 (51) hide show
  1. package/dist/Button/Button.d.ts.map +1 -1
  2. package/dist/Button/Button.js +38 -14
  3. package/dist/MultiSelector/hooks.d.ts.map +1 -1
  4. package/dist/MultiSelector/hooks.js +3 -1
  5. package/dist/Pagination/Pagination.d.ts +8 -5
  6. package/dist/Pagination/Pagination.d.ts.map +1 -1
  7. package/dist/Pagination/Pagination.js +21 -8
  8. package/dist/PowerSearch/PowerSearch.js +1 -1
  9. package/dist/Switch/Switch.d.ts +1 -1
  10. package/dist/Switch/Switch.d.ts.map +1 -1
  11. package/dist/Switch/Switch.js +6 -0
  12. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  13. package/dist/Tooltip/useTooltip.js +11 -0
  14. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  15. package/dist/Typeahead/BaseTypeahead.js +26 -0
  16. package/dist/astryx.css +1 -0
  17. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  18. package/dist/i18n/generated-locales/en.generated.js +19 -0
  19. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  20. package/dist/i18n/generated-locales/pseudo.generated.js +19 -0
  21. package/locales/en.json +76 -0
  22. package/locales/pseudo.json +57 -0
  23. package/package.json +4 -4
  24. package/src/Button/Button.doc.mjs +2 -2
  25. package/src/Button/Button.test.tsx +155 -9
  26. package/src/Button/Button.tsx +39 -16
  27. package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
  28. package/src/Button/__tests__/Button.a11y.states.ts +0 -10
  29. package/src/IconButton/IconButton.doc.mjs +2 -2
  30. package/src/IconButton/IconButton.test.tsx +3 -1
  31. package/src/MultiSelector/hooks.ts +3 -1
  32. package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
  33. package/src/Pagination/Pagination.doc.mjs +7 -7
  34. package/src/Pagination/Pagination.spec.md +9 -9
  35. package/src/Pagination/Pagination.test.tsx +242 -0
  36. package/src/Pagination/Pagination.tsx +27 -11
  37. package/src/PowerSearch/PowerSearch.test.tsx +85 -0
  38. package/src/PowerSearch/PowerSearch.tsx +2 -2
  39. package/src/Switch/Switch.test.tsx +28 -0
  40. package/src/Switch/Switch.tsx +6 -0
  41. package/src/Tokenizer/Tokenizer.test.tsx +54 -1
  42. package/src/Tooltip/useTooltip.test.tsx +13 -0
  43. package/src/Tooltip/useTooltip.tsx +11 -0
  44. package/src/Typeahead/BaseTypeahead.tsx +26 -0
  45. package/src/Typeahead/Typeahead.test.tsx +260 -2
  46. package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
  47. package/src/i18n/InternationalizationProvider.spec.md +252 -0
  48. package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
  49. package/src/i18n/generated-locales/en.generated.ts +21 -0
  50. package/src/i18n/generated-locales/pseudo.generated.ts +21 -0
  51. package/src/i18n/useTranslator.test.tsx +37 -0
@@ -5,9 +5,10 @@
5
5
  /**
6
6
  * @file Pagination.tsx
7
7
  * @input Uses React, StyleX, Button, Icon, Selector, Text; page number buttons delegate to Button.
8
+ * Compact first/last buttons cancel the inside control gap with logical token margins.
8
9
  * Prev/next and first/last chevrons mirror under RTL via the shared rtlStyles.mirror
9
- * (CSS scaleX), not a JS direction read. The input variant uses the chevronsLeft/chevronsRight
10
- * (first/last) icons.
10
+ * (CSS scaleX), not a JS direction read. The input and compact variants use the
11
+ * chevronsLeft/chevronsRight (first/last) icons.
11
12
  * @output Exports Pagination component, PaginationProps, PaginationVariant, PaginationSize types
12
13
  * @position Core implementation; consumed by index.ts, tested by Pagination.test.tsx
13
14
  *
@@ -105,7 +106,8 @@ export interface PaginationProps extends Omit<
105
106
  * Visual variant controlling what appears between prev/next buttons.
106
107
  * - pages: Page number buttons with ellipsis (default)
107
108
  * - count: "X–Y of Z" text
108
- * - compact: "Page X of Y" text
109
+ * - compact: "Page X of Y" text. First/last buttons are opt-in (see
110
+ * hasFirstLast).
109
111
  * - dots: Dot indicators
110
112
  * - input: An editable number box rendering "Page [ n ] / N". The leading
111
113
  * noun is controlled by `pageLabel`. First/last double-chevron buttons
@@ -122,9 +124,11 @@ export interface PaginationProps extends Omit<
122
124
  pageLabel?: string;
123
125
  /**
124
126
  * Whether to show first/last («/») double-chevron buttons flanking
125
- * prev/next. Only applies to the `input` variant; other variants ignore it.
126
- * The last button needs a known total — it is omitted when the page count is
127
- * unknown (cursor/hasMore pagination). @default true
127
+ * prev/next. Applies to the `input` variant (shown unless false) and the
128
+ * `compact` variant (shown only when true); other variants ignore it.
129
+ * The last button needs a known total — both are omitted when the page count
130
+ * is unknown (cursor/hasMore pagination).
131
+ * @default true for `input`, false for `compact`
128
132
  */
129
133
  hasFirstLast?: boolean;
130
134
  /**
@@ -181,6 +185,12 @@ const styles = stylex.create({
181
185
  alignItems: 'center',
182
186
  gap: spacingVars['--spacing-1'],
183
187
  },
188
+ compactFirst: {
189
+ marginInlineEnd: `calc(-1 * ${spacingVars['--spacing-1']})`,
190
+ },
191
+ compactLast: {
192
+ marginInlineStart: `calc(-1 * ${spacingVars['--spacing-1']})`,
193
+ },
184
194
  ellipsis: {
185
195
  display: 'inline-flex',
186
196
  alignItems: 'center',
@@ -369,7 +379,7 @@ export function Pagination({
369
379
  onPageSizeChange,
370
380
  variant = 'pages',
371
381
  pageLabel,
372
- hasFirstLast = true,
382
+ hasFirstLast: hasFirstLastProp,
373
383
  step: stepProp = 1,
374
384
  siblingCount = 1,
375
385
  size = 'md',
@@ -572,10 +582,14 @@ export function Pagination({
572
582
  const buttonSize = size === 'sm' ? 'sm' : 'md';
573
583
  const isSm = size === 'sm';
574
584
 
575
- // First/last buttons only exist in the input variant, and only when the page
576
- // count is known (they'd have no target otherwise).
577
- const showFirstLast =
578
- hasFirstLast && variant === 'input' && computedTotalPages != null;
585
+ // First/last buttons are on by default in the input variant and opt-in for
586
+ // compact; other variants never render them. They also need a known page
587
+ // count (the last button would have no target otherwise).
588
+ const hasFirstLast =
589
+ variant === 'input'
590
+ ? (hasFirstLastProp ?? true)
591
+ : variant === 'compact' && hasFirstLastProp === true;
592
+ const showFirstLast = hasFirstLast && computedTotalPages != null;
579
593
 
580
594
  const renderIndicator = () => {
581
595
  switch (variant) {
@@ -819,6 +833,7 @@ export function Pagination({
819
833
  onClick={handleFirst}
820
834
  isDisabled={isDisabled || !hasPrevious}
821
835
  isIconOnly
836
+ xstyle={variant === 'compact' && styles.compactFirst}
822
837
  />
823
838
  )}
824
839
 
@@ -874,6 +889,7 @@ export function Pagination({
874
889
  onClick={handleLast}
875
890
  isDisabled={isDisabled || !hasNext}
876
891
  isIconOnly
892
+ xstyle={variant === 'compact' && styles.compactLast}
877
893
  />
878
894
  )}
879
895
  </div>
@@ -165,6 +165,91 @@ describe('PowerSearch', () => {
165
165
  expect(screen.getByRole('combobox')).toHaveFocus();
166
166
  });
167
167
 
168
+ it('clears all added filters when the clear button is clicked', async () => {
169
+ const user = userEvent.setup();
170
+ render(<PowerSearchWrapper config={config} />);
171
+ const input = screen.getByRole('combobox', {name: 'Search'});
172
+ await user.type(input, 'status open');
173
+ await user.click(
174
+ await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
175
+ );
176
+ await user.type(input, 'title login');
177
+ await user.click(
178
+ await screen.findByRole('option', {
179
+ name: 'Title contains "login"',
180
+ hidden: true,
181
+ }),
182
+ );
183
+
184
+ await user.type(input, 'status closed');
185
+ await user.click(
186
+ await screen.findByRole('option', {
187
+ name: 'Status is Closed',
188
+ hidden: true,
189
+ }),
190
+ );
191
+
192
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(3);
193
+
194
+ await user.click(screen.getByRole('button', {name: 'Clear all'}));
195
+
196
+ expect(screen.queryAllByRole('button', {name: /^Remove /})).toHaveLength(0);
197
+ expect(
198
+ screen.queryByRole('button', {name: 'Clear all'}),
199
+ ).not.toBeInTheDocument();
200
+ });
201
+
202
+ it('clicking the remove button on a filter removes only that filter', async () => {
203
+ const user = userEvent.setup();
204
+ render(<PowerSearchWrapper config={config} />);
205
+ const input = screen.getByRole('combobox', {name: 'Search'});
206
+ await user.type(input, 'status open');
207
+ await user.click(
208
+ await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
209
+ );
210
+ await user.type(input, 'title login');
211
+ await user.click(
212
+ await screen.findByRole('option', {
213
+ name: 'Title contains "login"',
214
+ hidden: true,
215
+ }),
216
+ );
217
+
218
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(2);
219
+
220
+ await user.click(
221
+ screen.getAllByRole('button', {name: 'Remove Status: is'})[0],
222
+ );
223
+
224
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(1);
225
+ expect(screen.getByText('login')).toBeInTheDocument();
226
+ });
227
+
228
+ it('Backspace on an empty input removes only the last filter', async () => {
229
+ const user = userEvent.setup();
230
+ render(<PowerSearchWrapper config={config} />);
231
+ const input = screen.getByRole('combobox', {name: 'Search'});
232
+ await user.type(input, 'status open');
233
+ await user.click(
234
+ await screen.findByRole('option', {name: 'Status is Open', hidden: true}),
235
+ );
236
+ await user.type(input, 'title login');
237
+ await user.click(
238
+ await screen.findByRole('option', {
239
+ name: 'Title contains "login"',
240
+ hidden: true,
241
+ }),
242
+ );
243
+
244
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(2);
245
+
246
+ await user.click(input);
247
+ await user.keyboard('{Backspace}');
248
+
249
+ expect(screen.getAllByRole('button', {name: /^Remove /})).toHaveLength(1);
250
+ expect(screen.getByText('Open')).toBeInTheDocument();
251
+ });
252
+
168
253
  describe('token value truncation (#4759)', () => {
169
254
  it('truncates token values by characters, not code units', () => {
170
255
  const truncConfig: PowerSearchConfig = {
@@ -733,8 +733,8 @@ export function PowerSearch({
733
733
  } else if (change.type === 'remove' && change.item) {
734
734
  const auxData = change.item.auxiliaryData as PowerSearchAuxData;
735
735
  if (auxData?.filterIndex != null) {
736
- const newFilters = filters.filter(
737
- (_, i) => i !== auxData.filterIndex,
736
+ const newFilters = filters.filter((_, i) =>
737
+ _items.some(item => item.auxiliaryData?.filterIndex === i),
738
738
  );
739
739
  onChange(newFilters, 'remove', auxData.filterIndex);
740
740
  }
@@ -834,6 +834,34 @@ describe('Switch', () => {
834
834
  expect(root).toHaveAttribute('id', 'switch-1');
835
835
  expect(root).toHaveAttribute('aria-label', 'Toggle notifications');
836
836
  });
837
+
838
+ it('composes a caller aria-describedby onto the switch control', () => {
839
+ const {container} = render(
840
+ <>
841
+ <p id="external-hint">Applies to every repository</p>
842
+ <Switch
843
+ label="Notifications"
844
+ description="Sends a daily digest"
845
+ value={false}
846
+ onChange={() => {}}
847
+ data-testid="my-switch"
848
+ aria-describedby="external-hint"
849
+ />
850
+ </>,
851
+ );
852
+ const control = screen.getByRole('switch');
853
+ const description = screen.getByText('Sends a daily digest');
854
+ expect(control.getAttribute('aria-describedby')?.split(' ')).toEqual([
855
+ 'external-hint',
856
+ description.id,
857
+ ]);
858
+ expect(control).toHaveAccessibleDescription(
859
+ 'Applies to every repository Sends a daily digest',
860
+ );
861
+ // The id reference lives on the control only, not on the field wrapper.
862
+ const root = container.querySelector('[data-testid="my-switch"]');
863
+ expect(root).not.toHaveAttribute('aria-describedby');
864
+ });
837
865
  });
838
866
 
839
867
  describe('pressed state', () => {
@@ -566,6 +566,7 @@ export function Switch({
566
566
  className,
567
567
  style,
568
568
  ref,
569
+ 'aria-describedby': ariaDescribedByProp,
569
570
  ...rest
570
571
  }: SwitchProps) {
571
572
  const t = useTranslator();
@@ -610,7 +611,12 @@ export function Switch({
610
611
  // Only include descriptionID when the element actually renders.
611
612
  // FieldLabel renders the description (with descriptionID) even when the
612
613
  // label is visually hidden — it's sr-only, so keep it linked.
614
+ // A consumer's own `aria-describedby` describes the control, so it comes
615
+ // first here rather than landing on the field wrapper via `...rest`.
613
616
  const describedByParts: string[] = [];
617
+ if (ariaDescribedByProp) {
618
+ describedByParts.push(ariaDescribedByProp);
619
+ }
614
620
  if (description) {
615
621
  describedByParts.push(descriptionID);
616
622
  }
@@ -20,7 +20,7 @@ import {
20
20
  afterEach,
21
21
  } from 'vitest';
22
22
  import {render, screen, fireEvent, act, waitFor} from '@testing-library/react';
23
- import {createRef, Profiler} from 'react';
23
+ import {createRef, Profiler, useState} from 'react';
24
24
  import userEvent from '@testing-library/user-event';
25
25
  import {Tokenizer} from './Tokenizer';
26
26
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
@@ -1790,6 +1790,59 @@ describe('Tokenizer end-lane reserve', () => {
1790
1790
  expect(renderedTokens).toEqual([]);
1791
1791
  });
1792
1792
 
1793
+ it('clears the busy spinner when a token is added while a bootstrap load is in flight', async () => {
1794
+ let settle: (items: SearchableItem[]) => void = () => {};
1795
+ const bootstrap = vi.fn(
1796
+ async () =>
1797
+ new Promise<SearchableItem[]>(resolve => {
1798
+ settle = resolve;
1799
+ }),
1800
+ );
1801
+ const source: SearchSource = {search: () => [], bootstrap};
1802
+
1803
+ function Harness() {
1804
+ const [value, setValue] = useState<SearchableItem[]>([]);
1805
+ return (
1806
+ <>
1807
+ <Tokenizer
1808
+ label="Items"
1809
+ searchSource={source}
1810
+ value={value}
1811
+ onChange={setValue}
1812
+ hasEntriesOnFocus
1813
+ />
1814
+ <button type="button" onClick={() => setValue([users[0]])}>
1815
+ Add Token
1816
+ </button>
1817
+ </>
1818
+ );
1819
+ }
1820
+
1821
+ render(<Harness />);
1822
+ const input = screen.getByRole('combobox');
1823
+
1824
+ fireEvent.focus(input);
1825
+ await waitFor(() => {
1826
+ expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument();
1827
+ });
1828
+
1829
+ fireEvent.click(screen.getByRole('button', {name: 'Add Token'}));
1830
+
1831
+ await waitFor(() => {
1832
+ expect(
1833
+ screen.queryByRole('status', {name: 'Loading'}),
1834
+ ).not.toBeInTheDocument();
1835
+ });
1836
+
1837
+ await act(async () => {
1838
+ settle([]);
1839
+ await Promise.resolve();
1840
+ });
1841
+ expect(
1842
+ screen.queryByRole('status', {name: 'Loading'}),
1843
+ ).not.toBeInTheDocument();
1844
+ });
1845
+
1793
1846
  it('shares one observer across every field on the page', () => {
1794
1847
  // Browsers batch per observer instance, so one observer per lane meant N
1795
1848
  // callback dispatches a frame for N fields.
@@ -147,6 +147,19 @@ describe('useTooltip — show delay', () => {
147
147
 
148
148
  expect(onShow).not.toHaveBeenCalled();
149
149
  });
150
+
151
+ it('drops a pending show when isEnabled turns false before the delay elapses', () => {
152
+ const onShow = vi.fn();
153
+ const {rerender} = render(<Harness onShow={onShow} />);
154
+
155
+ fireEvent.mouseEnter(trigger());
156
+ tick(100);
157
+ rerender(<Harness isEnabled={false} onShow={onShow} />);
158
+ tick(1000);
159
+
160
+ expect(onShow).not.toHaveBeenCalled();
161
+ expect(tooltipElement().matches(':popover-open')).toBe(false);
162
+ });
150
163
  });
151
164
 
152
165
  describe('useTooltip — touch suppression', () => {
@@ -352,6 +352,17 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
352
352
  }, delay);
353
353
  }, [isEnabled, isOpen, clearTimeouts, showLayer, delay]);
354
354
 
355
+ // A show scheduled while the triggers were enabled must not outlive them:
356
+ // disabling also detaches the leave listener that would have cancelled it,
357
+ // so the timer would open a tooltip that nothing ever closes. Only the
358
+ // pending show is dropped; a pending hide still runs.
359
+ useEffect(() => {
360
+ if (!isEnabled && showTimeoutRef.current) {
361
+ clearTimeout(showTimeoutRef.current);
362
+ showTimeoutRef.current = null;
363
+ }
364
+ }, [isEnabled]);
365
+
355
366
  // Schedule hide with delay (suppressed when isOpen is true).
356
367
  // A small hover bridge (when hideDelay is 0) lets the pointer travel from the
357
368
  // trigger onto the tooltip surface without the tooltip vanishing — required
@@ -624,6 +624,32 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
624
624
  sourceGenRef.current = ++searchGenRef.current;
625
625
  }
626
626
 
627
+ // The previous source is remembered in state, not a ref, so a render React
628
+ // discards cannot consume the comparison without its state updates.
629
+ const [sourceOfState, setSourceOfState] = useState(searchSource);
630
+ if (sourceOfState !== searchSource) {
631
+ setSourceOfState(searchSource);
632
+ if (isLoading) {
633
+ setIsLoading(false);
634
+ setHasSearched(false);
635
+ }
636
+ loadingRef.current = false;
637
+ }
638
+
639
+ // When the search source is replaced, any in-flight load from the previous
640
+ // source is abandoned (generation bumped above). Notify the wrapper's busy lane
641
+ // in an effect so external store listeners run safely after commit.
642
+ const isInitialMountRef = useRef(true);
643
+ useEffect(() => {
644
+ if (isInitialMountRef.current) {
645
+ isInitialMountRef.current = false;
646
+ return;
647
+ }
648
+ if (!loadingRef.current) {
649
+ onLoadingChangeRef.current?.(false);
650
+ }
651
+ }, [searchSource]);
652
+
627
653
  // Layer for dropdown
628
654
  const handleLayerShow = useCallback(() => {
629
655
  onOpenChange?.(true);