@ankhorage/zora 1.1.0 → 1.3.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 (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +129 -5
  3. package/dist/components/app-bar/AppBar.d.ts +4 -0
  4. package/dist/components/app-bar/AppBar.d.ts.map +1 -0
  5. package/dist/components/app-bar/AppBar.js +63 -0
  6. package/dist/components/app-bar/AppBar.js.map +1 -0
  7. package/dist/components/app-bar/index.d.ts +3 -0
  8. package/dist/components/app-bar/index.d.ts.map +1 -0
  9. package/dist/components/app-bar/index.js +3 -0
  10. package/dist/components/app-bar/index.js.map +1 -0
  11. package/dist/components/app-bar/types.d.ts +31 -0
  12. package/dist/components/app-bar/types.d.ts.map +1 -0
  13. package/dist/components/app-bar/types.js +2 -0
  14. package/dist/components/app-bar/types.js.map +1 -0
  15. package/dist/components/input/types.d.ts +1 -1
  16. package/dist/components/input/types.d.ts.map +1 -1
  17. package/dist/components/input/types.js.map +1 -1
  18. package/dist/components/toolbar/types.d.ts +1 -1
  19. package/dist/components/toolbar/types.d.ts.map +1 -1
  20. package/dist/components/toolbar/types.js.map +1 -1
  21. package/dist/index.d.ts +7 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/internal/resolveZoraNavigationItems.d.ts +4 -3
  26. package/dist/internal/resolveZoraNavigationItems.d.ts.map +1 -1
  27. package/dist/internal/resolveZoraNavigationItems.js.map +1 -1
  28. package/dist/patterns/list/types.d.ts +2 -2
  29. package/dist/patterns/list/types.d.ts.map +1 -1
  30. package/dist/patterns/list/types.js.map +1 -1
  31. package/dist/patterns/responsive-panel/types.d.ts +1 -1
  32. package/dist/patterns/responsive-panel/types.d.ts.map +1 -1
  33. package/dist/patterns/responsive-panel/types.js.map +1 -1
  34. package/dist/patterns/selection/SelectableItem.d.ts +4 -0
  35. package/dist/patterns/selection/SelectableItem.d.ts.map +1 -0
  36. package/dist/patterns/selection/SelectableItem.js +72 -0
  37. package/dist/patterns/selection/SelectableItem.js.map +1 -0
  38. package/dist/patterns/selection/SelectionProvider.d.ts +5 -0
  39. package/dist/patterns/selection/SelectionProvider.d.ts.map +1 -0
  40. package/dist/patterns/selection/SelectionProvider.js +64 -0
  41. package/dist/patterns/selection/SelectionProvider.js.map +1 -0
  42. package/dist/patterns/selection/index.d.ts +4 -0
  43. package/dist/patterns/selection/index.d.ts.map +1 -0
  44. package/dist/patterns/selection/index.js +3 -0
  45. package/dist/patterns/selection/index.js.map +1 -0
  46. package/dist/patterns/selection/resolveSelectionNextIds.d.ts +15 -0
  47. package/dist/patterns/selection/resolveSelectionNextIds.d.ts.map +1 -0
  48. package/dist/patterns/selection/resolveSelectionNextIds.js +44 -0
  49. package/dist/patterns/selection/resolveSelectionNextIds.js.map +1 -0
  50. package/dist/patterns/selection/types.d.ts +38 -0
  51. package/dist/patterns/selection/types.d.ts.map +1 -0
  52. package/dist/patterns/selection/types.js +2 -0
  53. package/dist/patterns/selection/types.js.map +1 -0
  54. package/package.json +8 -9
  55. package/src/components/app-bar/AppBar.tsx +133 -0
  56. package/src/components/app-bar/index.ts +2 -0
  57. package/src/components/app-bar/types.ts +36 -0
  58. package/src/components/input/types.ts +1 -1
  59. package/src/components/toolbar/types.ts +2 -2
  60. package/src/index.ts +19 -3
  61. package/src/internal/resolveZoraNavigationItems.ts +3 -3
  62. package/src/patterns/list/types.ts +2 -2
  63. package/src/patterns/responsive-panel/types.ts +2 -2
  64. package/src/patterns/selection/SelectableItem.tsx +93 -0
  65. package/src/patterns/selection/SelectionProvider.tsx +102 -0
  66. package/src/patterns/selection/index.ts +10 -0
  67. package/src/patterns/selection/resolveSelectionNextIds.test.ts +61 -0
  68. package/src/patterns/selection/resolveSelectionNextIds.ts +71 -0
  69. package/src/patterns/selection/types.ts +43 -0
  70. package/src/showcaseCoverage.test.ts +4 -0
  71. package/src/theme/themeScopeStructure.test.ts +2 -0
@@ -0,0 +1,36 @@
1
+ import type { ButtonIconSpec } from '@ankhorage/surface';
2
+ import type React from 'react';
3
+
4
+ import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
5
+
6
+ export type AppBarMode =
7
+ | {
8
+ type: 'default';
9
+ }
10
+ | {
11
+ type: 'selection';
12
+ label: string;
13
+ count?: number;
14
+ onCancel: () => void;
15
+ cancelLabel?: string;
16
+ cancelIcon?: ButtonIconSpec;
17
+ };
18
+
19
+ export interface AppBarOverflowAction {
20
+ onPress: () => void;
21
+ label?: string;
22
+ icon?: ButtonIconSpec;
23
+ disabled?: boolean;
24
+ }
25
+
26
+ export interface AppBarProps extends ZoraBaseProps {
27
+ title?: React.ReactNode;
28
+ subtitle?: React.ReactNode;
29
+ leading?: React.ReactNode;
30
+ actions?: React.ReactNode;
31
+ overflow?: AppBarOverflowAction;
32
+ appMode?: AppBarMode;
33
+ children?: React.ReactNode;
34
+ safeAreaTop?: boolean;
35
+ divider?: boolean;
36
+ }
@@ -19,7 +19,7 @@ type InputTrailingProps =
19
19
  trailingAction?: InputTrailingAction;
20
20
  };
21
21
 
22
- export interface InputBaseProps
22
+ interface InputBaseProps
23
23
  extends
24
24
  ZoraBaseProps,
25
25
  Omit<
@@ -1,10 +1,10 @@
1
1
  import type { ButtonIconSpec } from '@ankhorage/surface';
2
2
  import type React from 'react';
3
3
 
4
- export type ToolbarPosition = 'top' | 'bottom' | 'inline';
5
-
6
4
  import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
7
5
 
6
+ export type ToolbarPosition = 'top' | 'bottom' | 'inline';
7
+
8
8
  export interface ToolbarProps extends ZoraBaseProps {
9
9
  children?: React.ReactNode;
10
10
  position?: ToolbarPosition;
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type { AppBarMode, AppBarOverflowAction, AppBarProps } from './components/app-bar';
2
+ export { AppBar } from './components/app-bar';
1
3
  export type { AvatarProps, AvatarShape, AvatarSize } from './components/avatar';
2
4
  export { Avatar, resolveAvatarInitials } from './components/avatar';
3
5
  export type { AvatarGroupItem, AvatarGroupProps } from './components/avatar-group';
@@ -86,13 +88,13 @@ export type { SearchBarProps } from './components/search-bar';
86
88
  export { SearchBar } from './components/search-bar';
87
89
  export type { SelectOption, SelectProps } from './components/select';
88
90
  export { Select } from './components/select';
89
- export type { TabItem, TabsProps } from './components/tabs';
91
+ export type { TabItem, TabsProps, TabsVariant } from './components/tabs';
90
92
  export { Tabs } from './components/tabs';
91
93
  export type { TextAlign, TextProps, TextTone, TextVariant, TextWeight } from './components/text';
92
94
  export { Text } from './components/text';
93
95
  export type { TextareaProps } from './components/textarea';
94
96
  export { Textarea } from './components/textarea';
95
- export type { ToolbarActionProps, ToolbarProps } from './components/toolbar';
97
+ export type { ToolbarActionProps, ToolbarPosition, ToolbarProps } from './components/toolbar';
96
98
  export { Toolbar, ToolbarAction } from './components/toolbar';
97
99
  export type {
98
100
  BoxProps,
@@ -189,10 +191,24 @@ export type { NoticeProps } from './patterns/notice';
189
191
  export { Notice } from './patterns/notice';
190
192
  export type { PanelProps } from './patterns/panel';
191
193
  export { Panel } from './patterns/panel';
192
- export type { ResponsivePanelProps } from './patterns/responsive-panel';
194
+ export type {
195
+ ResponsivePanelDesktopMode,
196
+ ResponsivePanelMobileMode,
197
+ ResponsivePanelProps,
198
+ ResponsivePanelSide,
199
+ } from './patterns/responsive-panel';
193
200
  export { ResponsivePanel } from './patterns/responsive-panel';
194
201
  export type { SectionHeaderProps } from './patterns/section-header';
195
202
  export { SectionHeader } from './patterns/section-header';
203
+ export type {
204
+ SelectableItemProps,
205
+ SelectableItemState,
206
+ SelectionMode,
207
+ SelectionProviderProps,
208
+ SelectionTrigger,
209
+ UseSelectionResult,
210
+ } from './patterns/selection';
211
+ export { SelectableItem, SelectionProvider, useSelection } from './patterns/selection';
196
212
  export type { SettingsRowProps } from './patterns/settings-row';
197
213
  export { SettingsRow } from './patterns/settings-row';
198
214
  export type { SwitchFieldProps } from './patterns/switch-field';
@@ -6,7 +6,7 @@ import type {
6
6
  } from '../components/navigation-item';
7
7
  import type { ZoraNavigationRouteMap } from '../components/navigation-list';
8
8
 
9
- export interface ZoraNavigationDescriptorOptions {
9
+ interface ZoraNavigationDescriptorOptions {
10
10
  title?: string;
11
11
  tabBarLabel?: string | React.ReactNode;
12
12
  drawerLabel?: string | React.ReactNode;
@@ -25,13 +25,13 @@ export interface ZoraNavigationState {
25
25
  routes: readonly ZoraNavigationRouteState[];
26
26
  }
27
27
 
28
- export interface ZoraTabPressEvent {
28
+ interface ZoraTabPressEvent {
29
29
  type: 'tabPress';
30
30
  target: string;
31
31
  canPreventDefault: true;
32
32
  }
33
33
 
34
- export interface ZoraTabPressEventResult {
34
+ interface ZoraTabPressEventResult {
35
35
  defaultPrevented: boolean;
36
36
  }
37
37
 
@@ -46,7 +46,7 @@ export interface ListChildrenProps extends ZoraBaseProps {
46
46
 
47
47
  export type ListProps = ListItemsProps | ListChildrenProps;
48
48
 
49
- export interface ListSectionItemsProps extends ZoraBaseProps {
49
+ interface ListSectionItemsProps extends ZoraBaseProps {
50
50
  title?: React.ReactNode;
51
51
  description?: React.ReactNode;
52
52
  eyebrow?: React.ReactNode;
@@ -56,7 +56,7 @@ export interface ListSectionItemsProps extends ZoraBaseProps {
56
56
  compact?: boolean;
57
57
  }
58
58
 
59
- export interface ListSectionChildrenProps extends ZoraBaseProps {
59
+ interface ListSectionChildrenProps extends ZoraBaseProps {
60
60
  title?: React.ReactNode;
61
61
  description?: React.ReactNode;
62
62
  eyebrow?: React.ReactNode;
@@ -1,11 +1,11 @@
1
1
  import type React from 'react';
2
2
 
3
+ import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
4
+
3
5
  export type ResponsivePanelSide = 'left' | 'right';
4
6
  export type ResponsivePanelDesktopMode = 'inline' | 'floating';
5
7
  export type ResponsivePanelMobileMode = 'drawer' | 'modal';
6
8
 
7
- import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
8
-
9
9
  export interface ResponsivePanelProps extends ZoraBaseProps {
10
10
  title?: React.ReactNode;
11
11
  description?: React.ReactNode;
@@ -0,0 +1,93 @@
1
+ import { ButtonBase } from '@ankhorage/surface';
2
+ import React from 'react';
3
+ import type { GestureResponderEvent } from 'react-native';
4
+
5
+ import { useSelection } from './SelectionProvider';
6
+ import type { SelectableItemProps, SelectableItemState, SelectionTrigger } from './types';
7
+
8
+ function resolveTrigger(trigger: SelectionTrigger | undefined): SelectionTrigger {
9
+ return trigger ?? 'manual';
10
+ }
11
+
12
+ function isRenderProp(
13
+ children: SelectableItemProps['children'],
14
+ ): children is (state: SelectableItemState) => React.ReactNode {
15
+ return typeof children === 'function';
16
+ }
17
+
18
+ export function SelectableItem({ id, trigger, disabled = false, children }: SelectableItemProps) {
19
+ const selection = useSelection();
20
+ const resolvedTrigger = resolveTrigger(trigger);
21
+ const resolvedDisabled = selection.disabled || disabled;
22
+ const selected = selection.isSelected(id);
23
+
24
+ const select = React.useCallback(() => {
25
+ if (resolvedDisabled) return;
26
+ selection.select(id);
27
+ }, [id, resolvedDisabled, selection]);
28
+
29
+ const toggle = React.useCallback(() => {
30
+ if (resolvedDisabled) return;
31
+ selection.toggle(id);
32
+ }, [id, resolvedDisabled, selection]);
33
+
34
+ const clear = React.useCallback(() => {
35
+ if (selection.disabled) return;
36
+ selection.clear();
37
+ }, [selection]);
38
+
39
+ const itemState = React.useMemo<SelectableItemState>(() => {
40
+ return {
41
+ id,
42
+ selected,
43
+ disabled: resolvedDisabled,
44
+ mode: selection.mode,
45
+ select,
46
+ toggle,
47
+ clear,
48
+ };
49
+ }, [clear, id, resolvedDisabled, select, selected, selection.mode, toggle]);
50
+
51
+ // IMPORTANT:
52
+ // Do not pass `children` directly into ButtonBase. ButtonBase also supports function children,
53
+ // but its function signature receives interaction state, not SelectableItemState.
54
+ const content = isRenderProp(children) ? children(itemState) : children;
55
+
56
+ if (resolvedTrigger === 'manual') {
57
+ return <>{content}</>;
58
+ }
59
+
60
+ const handlePress = (event: GestureResponderEvent) => {
61
+ event.stopPropagation();
62
+ if (resolvedDisabled) return;
63
+ if (selection.mode === 'single') {
64
+ selection.select(id);
65
+ return;
66
+ }
67
+
68
+ selection.toggle(id);
69
+ };
70
+
71
+ const handleLongPress = (event: GestureResponderEvent) => {
72
+ event.stopPropagation();
73
+ if (resolvedDisabled) return;
74
+ if (selection.mode === 'single') {
75
+ selection.select(id);
76
+ return;
77
+ }
78
+
79
+ selection.toggle(id);
80
+ };
81
+
82
+ return (
83
+ <ButtonBase
84
+ accessibilityRole="button"
85
+ accessibilityState={{ disabled: resolvedDisabled, selected }}
86
+ disabled={resolvedDisabled}
87
+ onLongPress={resolvedTrigger === 'longPress' ? handleLongPress : undefined}
88
+ onPress={resolvedTrigger === 'press' ? handlePress : undefined}
89
+ >
90
+ {content}
91
+ </ButtonBase>
92
+ );
93
+ }
@@ -0,0 +1,102 @@
1
+ import React from 'react';
2
+
3
+ import { areIdsEqual, clearIds, normalizeIds, selectId, toggleId } from './resolveSelectionNextIds';
4
+ import type { SelectionMode, SelectionProviderProps, UseSelectionResult } from './types';
5
+
6
+ const MISSING_CONTEXT_MESSAGE =
7
+ 'ZORA selection context is missing. Wrap this tree in <SelectionProvider>.';
8
+
9
+ type SelectionContextValue = UseSelectionResult;
10
+
11
+ const SelectionContext = React.createContext<SelectionContextValue | null>(null);
12
+
13
+ function resolveMode(mode: SelectionMode | undefined): SelectionMode {
14
+ return mode ?? 'single';
15
+ }
16
+
17
+ function resolveDisabled(disabled: boolean | undefined): boolean {
18
+ return disabled ?? false;
19
+ }
20
+
21
+ export function useSelection(): UseSelectionResult {
22
+ const value = React.useContext(SelectionContext);
23
+ if (!value) {
24
+ throw new Error(MISSING_CONTEXT_MESSAGE);
25
+ }
26
+
27
+ return value;
28
+ }
29
+
30
+ export function SelectionProvider({
31
+ children,
32
+ selectedIds,
33
+ defaultSelectedIds,
34
+ mode,
35
+ disabled,
36
+ onSelectionChange,
37
+ }: SelectionProviderProps) {
38
+ const resolvedMode = resolveMode(mode);
39
+ const resolvedDisabled = resolveDisabled(disabled);
40
+ const isControlled = selectedIds !== undefined;
41
+
42
+ const [uncontrolledIds, setUncontrolledIds] = React.useState<readonly string[]>(
43
+ defaultSelectedIds ?? [],
44
+ );
45
+
46
+ const rawIds = isControlled ? selectedIds : uncontrolledIds;
47
+ const currentNormalizedIds = normalizeIds(rawIds, resolvedMode);
48
+
49
+ const selectedIdSet = React.useMemo(() => new Set(currentNormalizedIds), [currentNormalizedIds]);
50
+
51
+ const commitSelectionChange = React.useCallback(
52
+ (nextNormalizedIds: readonly string[]) => {
53
+ if (resolvedDisabled) return;
54
+ if (areIdsEqual(nextNormalizedIds, currentNormalizedIds)) return;
55
+
56
+ onSelectionChange?.(nextNormalizedIds);
57
+
58
+ if (!isControlled) {
59
+ setUncontrolledIds(nextNormalizedIds);
60
+ }
61
+ },
62
+ [currentNormalizedIds, isControlled, onSelectionChange, resolvedDisabled],
63
+ );
64
+
65
+ const clear = React.useCallback(() => {
66
+ commitSelectionChange(normalizeIds(clearIds(), resolvedMode));
67
+ }, [commitSelectionChange, resolvedMode]);
68
+
69
+ const select = React.useCallback(
70
+ (id: string) => {
71
+ const nextIds = selectId({ mode: resolvedMode, ids: currentNormalizedIds, id });
72
+ const nextNormalizedIds = normalizeIds(nextIds, resolvedMode);
73
+ commitSelectionChange(nextNormalizedIds);
74
+ },
75
+ [commitSelectionChange, currentNormalizedIds, resolvedMode],
76
+ );
77
+
78
+ const toggle = React.useCallback(
79
+ (id: string) => {
80
+ const nextIds = toggleId({ mode: resolvedMode, ids: currentNormalizedIds, id });
81
+ const nextNormalizedIds = normalizeIds(nextIds, resolvedMode);
82
+ commitSelectionChange(nextNormalizedIds);
83
+ },
84
+ [commitSelectionChange, currentNormalizedIds, resolvedMode],
85
+ );
86
+
87
+ const value = React.useMemo<UseSelectionResult>(() => {
88
+ return {
89
+ mode: resolvedMode,
90
+ disabled: resolvedDisabled,
91
+ selectedIds: currentNormalizedIds,
92
+ selectedCount: currentNormalizedIds.length,
93
+ hasSelection: currentNormalizedIds.length > 0,
94
+ isSelected: (id: string) => selectedIdSet.has(id),
95
+ select,
96
+ toggle,
97
+ clear,
98
+ };
99
+ }, [clear, currentNormalizedIds, resolvedDisabled, resolvedMode, select, selectedIdSet, toggle]);
100
+
101
+ return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
102
+ }
@@ -0,0 +1,10 @@
1
+ export { SelectableItem } from './SelectableItem';
2
+ export { SelectionProvider, useSelection } from './SelectionProvider';
3
+ export type {
4
+ SelectableItemProps,
5
+ SelectableItemState,
6
+ SelectionMode,
7
+ SelectionProviderProps,
8
+ SelectionTrigger,
9
+ UseSelectionResult,
10
+ } from './types';
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { areIdsEqual, clearIds, normalizeIds, selectId, toggleId } from './resolveSelectionNextIds';
4
+ import type { SelectionMode } from './types';
5
+
6
+ describe('resolveSelectionNextIds', () => {
7
+ test('normalizeIds removes duplicates, preserves order', () => {
8
+ expect(normalizeIds(['a', 'b', 'a', 'c'], 'multi')).toEqual(['a', 'b', 'c']);
9
+ });
10
+
11
+ test('normalizeIds keeps only first id in single mode', () => {
12
+ expect(normalizeIds(['a', 'b', 'c'], 'single')).toEqual(['a']);
13
+ });
14
+
15
+ test('normalizeIds treats undefined as empty', () => {
16
+ expect(normalizeIds(undefined, 'multi')).toEqual([]);
17
+ });
18
+
19
+ test('areIdsEqual compares order', () => {
20
+ expect(areIdsEqual(['a'], ['a'])).toBeTrue();
21
+ expect(areIdsEqual(['a'], ['b'])).toBeFalse();
22
+ expect(areIdsEqual(['a', 'b'], ['a', 'b'])).toBeTrue();
23
+ expect(areIdsEqual(['a', 'b'], ['b', 'a'])).toBeFalse();
24
+ });
25
+
26
+ test('clearIds returns empty selection', () => {
27
+ expect(clearIds()).toEqual([]);
28
+ });
29
+
30
+ test('selectId replaces selection in single mode', () => {
31
+ expect(selectId({ mode: 'single', ids: ['a'], id: 'b' })).toEqual(['b']);
32
+ });
33
+
34
+ test('selectId adds id in multi mode when missing', () => {
35
+ expect(selectId({ mode: 'multi', ids: ['a'], id: 'b' })).toEqual(['a', 'b']);
36
+ });
37
+
38
+ test('selectId is no-op in multi mode when already selected', () => {
39
+ const ids = ['a', 'b'];
40
+ expect(selectId({ mode: 'multi', ids, id: 'b' })).toBe(ids);
41
+ });
42
+
43
+ test('toggleId clears id when already selected (single mode)', () => {
44
+ expect(toggleId({ mode: 'single', ids: ['a'], id: 'a' })).toEqual([]);
45
+ });
46
+
47
+ test('toggleId selects id when not selected (single mode)', () => {
48
+ expect(toggleId({ mode: 'single', ids: [], id: 'a' })).toEqual(['a']);
49
+ });
50
+
51
+ test('toggleId toggles membership (multi mode)', () => {
52
+ expect(toggleId({ mode: 'multi', ids: ['a'], id: 'b' })).toEqual(['a', 'b']);
53
+ expect(toggleId({ mode: 'multi', ids: ['a', 'b'], id: 'b' })).toEqual(['a']);
54
+ });
55
+
56
+ test('mode-change normalization example', () => {
57
+ const internalIds = ['a', 'b'];
58
+ const mode: SelectionMode = 'single';
59
+ expect(normalizeIds(internalIds, mode)).toEqual(['a']);
60
+ });
61
+ });
@@ -0,0 +1,71 @@
1
+ import type { SelectionMode } from './types';
2
+
3
+ export function normalizeIds(
4
+ ids: readonly string[] | undefined,
5
+ mode: SelectionMode,
6
+ ): readonly string[] {
7
+ const uniqueIds: string[] = [];
8
+ const seen = new Set<string>();
9
+
10
+ for (const id of ids ?? []) {
11
+ if (seen.has(id)) continue;
12
+ seen.add(id);
13
+ uniqueIds.push(id);
14
+ if (mode === 'single') break;
15
+ }
16
+
17
+ return uniqueIds;
18
+ }
19
+
20
+ export function areIdsEqual(a: readonly string[], b: readonly string[]): boolean {
21
+ if (a.length !== b.length) return false;
22
+ for (let index = 0; index < a.length; index += 1) {
23
+ if (a[index] !== b[index]) return false;
24
+ }
25
+
26
+ return true;
27
+ }
28
+
29
+ export function clearIds(): readonly string[] {
30
+ return [];
31
+ }
32
+
33
+ export function selectId({
34
+ mode,
35
+ ids,
36
+ id,
37
+ }: {
38
+ mode: SelectionMode;
39
+ ids: readonly string[];
40
+ id: string;
41
+ }): readonly string[] {
42
+ if (mode === 'single') {
43
+ return [id];
44
+ }
45
+
46
+ if (ids.includes(id)) {
47
+ return ids;
48
+ }
49
+
50
+ return [...ids, id];
51
+ }
52
+
53
+ export function toggleId({
54
+ mode,
55
+ ids,
56
+ id,
57
+ }: {
58
+ mode: SelectionMode;
59
+ ids: readonly string[];
60
+ id: string;
61
+ }): readonly string[] {
62
+ if (ids.includes(id)) {
63
+ return ids.filter((existingId) => existingId !== id);
64
+ }
65
+
66
+ if (mode === 'single') {
67
+ return [id];
68
+ }
69
+
70
+ return [...ids, id];
71
+ }
@@ -0,0 +1,43 @@
1
+ import type React from 'react';
2
+
3
+ export type SelectionMode = 'single' | 'multi';
4
+
5
+ export type SelectionTrigger = 'press' | 'longPress' | 'manual';
6
+
7
+ export interface SelectionProviderProps {
8
+ children: React.ReactNode;
9
+ selectedIds?: readonly string[];
10
+ defaultSelectedIds?: readonly string[];
11
+ mode?: SelectionMode;
12
+ disabled?: boolean;
13
+ onSelectionChange?: (ids: readonly string[]) => void;
14
+ }
15
+
16
+ export interface UseSelectionResult {
17
+ mode: SelectionMode;
18
+ disabled: boolean;
19
+ selectedIds: readonly string[];
20
+ selectedCount: number;
21
+ hasSelection: boolean;
22
+ isSelected: (id: string) => boolean;
23
+ select: (id: string) => void;
24
+ toggle: (id: string) => void;
25
+ clear: () => void;
26
+ }
27
+
28
+ export interface SelectableItemState {
29
+ id: string;
30
+ selected: boolean;
31
+ disabled: boolean;
32
+ mode: SelectionMode;
33
+ select: () => void;
34
+ toggle: () => void;
35
+ clear: () => void;
36
+ }
37
+
38
+ export interface SelectableItemProps {
39
+ id: string;
40
+ trigger?: SelectionTrigger;
41
+ disabled?: boolean;
42
+ children: React.ReactNode | ((state: SelectableItemState) => React.ReactNode);
43
+ }
@@ -17,6 +17,7 @@ const IGNORED_DIRECTORY_NAMES = new Set([
17
17
 
18
18
  const REQUIRED_SHOWCASE_COVERAGE = {
19
19
  components: [
20
+ 'AppBar',
20
21
  'Avatar',
21
22
  'AvatarGroup',
22
23
  'Badge',
@@ -85,6 +86,9 @@ const REQUIRED_SHOWCASE_COVERAGE = {
85
86
  'DisclosureSection',
86
87
  'EmptyState',
87
88
  'FilterBar',
89
+ 'SelectableItem',
90
+ 'SelectionProvider',
91
+ 'useSelection',
88
92
  'List',
89
93
  'ListRow',
90
94
  'ListSection',
@@ -53,6 +53,7 @@ const scopeGuardDirs = [
53
53
  const scopeGuardFiles = scopeGuardDirs.flatMap(collectSourceFiles);
54
54
 
55
55
  const scopedComponentFiles = [
56
+ join(srcDir, 'components', 'app-bar', 'AppBar.tsx'),
56
57
  join(srcDir, 'components', 'badge', 'Badge.tsx'),
57
58
  join(srcDir, 'components', 'button', 'Button.tsx'),
58
59
  join(srcDir, 'components', 'card', 'Card.tsx'),
@@ -128,6 +129,7 @@ const scopedComponentFiles = [
128
129
  ] as const;
129
130
 
130
131
  const scopedPropTypeFiles = [
132
+ join(srcDir, 'components', 'app-bar', 'types.ts'),
131
133
  join(srcDir, 'components', 'badge', 'types.ts'),
132
134
  join(srcDir, 'components', 'button', 'types.ts'),
133
135
  join(srcDir, 'components', 'card', 'types.ts'),