@ziyarago/ui 0.0.0-stage → 0.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 (119) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +25 -0
  3. package/README.md +121 -2
  4. package/THIRD_PARTY_NOTICES.md +12 -0
  5. package/assets/3d/_sheet.png +0 -0
  6. package/assets/3d/archive.png +0 -0
  7. package/assets/3d/bag.png +0 -0
  8. package/assets/3d/cameraoff.png +0 -0
  9. package/assets/3d/comment.png +0 -0
  10. package/assets/3d/crowd.png +0 -0
  11. package/assets/3d/firstaid.png +0 -0
  12. package/assets/3d/flag.png +0 -0
  13. package/assets/3d/grain.png +0 -0
  14. package/assets/3d/guide.png +0 -0
  15. package/assets/3d/hotel.png +0 -0
  16. package/assets/3d/kaaba.png +0 -0
  17. package/assets/3d/logo.png +0 -0
  18. package/assets/3d/luggage.png +0 -0
  19. package/assets/3d/map.png +0 -0
  20. package/assets/3d/mosque.png +0 -0
  21. package/assets/3d/plane.png +0 -0
  22. package/assets/3d/profile.png +0 -0
  23. package/assets/3d/restaurant.png +0 -0
  24. package/assets/3d/ring360.png +0 -0
  25. package/assets/3d/shoes.png +0 -0
  26. package/assets/3d/star.png +0 -0
  27. package/assets/3d/store.png +0 -0
  28. package/assets/3d/taxi.png +0 -0
  29. package/assets/3d/threads.png +0 -0
  30. package/assets/3d/tour360.png +0 -0
  31. package/assets/3d/trash.png +0 -0
  32. package/assets/3d/umbrella.png +0 -0
  33. package/assets/3d/video.png +0 -0
  34. package/assets/3d/water.png +0 -0
  35. package/assets/brand/mark.png +0 -0
  36. package/assets/brand/ziyarago-logo-light.svg +21 -0
  37. package/assets/brand/ziyarago-logo.svg +21 -0
  38. package/assets/brand/ziyarago-mark-light.svg +13 -0
  39. package/assets/brand/ziyarago-mark.svg +13 -0
  40. package/assets/fonts/thmanyah/thmanyahsans-Black.otf +0 -0
  41. package/assets/fonts/thmanyah/thmanyahsans-Bold.otf +0 -0
  42. package/assets/fonts/thmanyah/thmanyahsans-Light.otf +0 -0
  43. package/assets/fonts/thmanyah/thmanyahsans-Medium.otf +0 -0
  44. package/assets/fonts/thmanyah/thmanyahsans-Regular.otf +0 -0
  45. package/assets/fonts/thmanyah/thmanyahserifdisplay-Black.otf +0 -0
  46. package/assets/fonts/thmanyah/thmanyahserifdisplay-Bold.otf +0 -0
  47. package/assets/fonts/thmanyah/thmanyahserifdisplay-Light.otf +0 -0
  48. package/assets/fonts/thmanyah/thmanyahserifdisplay-Medium.otf +0 -0
  49. package/assets/fonts/thmanyah/thmanyahserifdisplay-Regular.otf +0 -0
  50. package/assets/fonts/thmanyah/thmanyahseriftext-Black.otf +0 -0
  51. package/assets/fonts/thmanyah/thmanyahseriftext-Bold.otf +0 -0
  52. package/assets/fonts/thmanyah/thmanyahseriftext-Light.otf +0 -0
  53. package/assets/fonts/thmanyah/thmanyahseriftext-Medium.otf +0 -0
  54. package/assets/fonts/thmanyah/thmanyahseriftext-Regular.otf +0 -0
  55. package/dist/llms-full.txt +2293 -0
  56. package/dist/llms.txt +239 -0
  57. package/dist/manifest.json +10254 -0
  58. package/dist/style.css +1357 -0
  59. package/dist/tokens.css +48 -0
  60. package/package.json +118 -4
  61. package/src/catalog.js +4 -0
  62. package/src/core/components.js +124 -0
  63. package/src/core/components2.js +163 -0
  64. package/src/core/components3.js +35 -0
  65. package/src/core/components4.js +58 -0
  66. package/src/core/components6.js +48 -0
  67. package/src/core/components7.js +42 -0
  68. package/src/core/dialog.js +65 -0
  69. package/src/core/index.js +9 -0
  70. package/src/core/pages.js +51 -0
  71. package/src/meta/README.md +29 -0
  72. package/src/meta/actions.js +161 -0
  73. package/src/meta/data.js +327 -0
  74. package/src/meta/domain.js +212 -0
  75. package/src/meta/feedback.js +153 -0
  76. package/src/meta/forms.js +453 -0
  77. package/src/meta/groups.js +16 -0
  78. package/src/meta/index.js +14 -0
  79. package/src/meta/layout.js +159 -0
  80. package/src/meta/media.js +104 -0
  81. package/src/meta/navigation.js +99 -0
  82. package/src/meta/overlay.js +264 -0
  83. package/src/meta/pages.js +229 -0
  84. package/src/meta/templates.json +2422 -0
  85. package/src/meta/typography.js +58 -0
  86. package/src/nuxt/module.mjs +29 -0
  87. package/src/nuxt/plugin.mjs +9 -0
  88. package/src/react/index.js +31 -0
  89. package/src/styles/components.css +162 -0
  90. package/src/styles/components2.css +68 -0
  91. package/src/styles/components3.css +29 -0
  92. package/src/styles/components4.css +18 -0
  93. package/src/styles/components5.css +5 -0
  94. package/src/styles/components6.css +19 -0
  95. package/src/styles/components7.css +44 -0
  96. package/src/styles/components8.css +47 -0
  97. package/src/styles/effects.css +43 -0
  98. package/src/styles/fonts.css +17 -0
  99. package/src/styles/index.css +21 -0
  100. package/src/styles/sizes/actions.css +126 -0
  101. package/src/styles/sizes/data.css +161 -0
  102. package/src/styles/sizes/domain.css +35 -0
  103. package/src/styles/sizes/feedback.css +69 -0
  104. package/src/styles/sizes/forms.css +122 -0
  105. package/src/styles/sizes/layout.css +15 -0
  106. package/src/styles/sizes/media.css +28 -0
  107. package/src/styles/sizes/navigation.css +146 -0
  108. package/src/styles/sizes/overlay.css +79 -0
  109. package/src/styles/sizes/typography.css +12 -0
  110. package/src/styles/tokens.css +47 -0
  111. package/src/theme.js +52 -0
  112. package/src/vue/index.js +29 -0
  113. package/tokens/tokens.json +9 -0
  114. package/types/core.d.ts +4 -0
  115. package/types/nuxt.d.ts +11 -0
  116. package/types/react.d.ts +189 -0
  117. package/types/shared.d.ts +18 -0
  118. package/types/theme.d.ts +6 -0
  119. package/types/vue.d.ts +192 -0
@@ -0,0 +1,153 @@
1
+ // API metadata for the feedback group. Format: see src/meta/README.md
2
+ const SIZE = "'xs' | 'sm' | 'md' | 'lg' | 'xl'";
3
+ const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
4
+ const size = (what) => ({ type: SIZE, values: SIZES, default: 'md', description: `Scale of ${what}.` });
5
+ const TONE4 = "'info' | 'success' | 'warning' | 'danger'";
6
+
7
+ const alertProps = {
8
+ tone: { type: TONE4, values: ['info', 'success', 'warning', 'danger'], default: 'info', description: 'Color and icon: info, check, triangle-alert or circle-alert.' },
9
+ title: { type: 'string', description: 'Bold first line above the message.' },
10
+ size: size('padding, icon and text'),
11
+ };
12
+
13
+ export default {
14
+ Alert: {
15
+ description: 'Inline message block with a tone icon, optional title and close button; use for form errors, notices and results inside a page.',
16
+ props: {
17
+ ...alertProps,
18
+ banner: { type: 'boolean', default: false, description: 'Removes the border radius so the alert spans the full width like a Banner.' },
19
+ },
20
+ slots: { default: 'Message text.' },
21
+ events: { close: 'Called when the close button is clicked. The button is rendered only when this handler is set.' },
22
+ notes: ['Has role="alert", so screen readers announce it when it appears.', 'Not self-dismissing: hide it yourself in the close handler.'],
23
+ },
24
+
25
+ Banner: {
26
+ description: 'Full-width Alert without rounded corners; use at the top of a page or section for system-wide notices.',
27
+ props: {
28
+ tone: { ...alertProps.tone, forwarded: true },
29
+ title: { ...alertProps.title, forwarded: true },
30
+ size: size('padding, icon and text'),
31
+ },
32
+ slots: { default: 'Message text.' },
33
+ events: { close: 'Called when the close button is clicked. The button is rendered only when this handler is set.' },
34
+ notes: ['Same as Alert with banner enabled; all Alert props are forwarded.'],
35
+ },
36
+
37
+ Toast: {
38
+ description: 'Static toast card with a tone icon, title and text; use for transient confirmations rendered in your own toast stack.',
39
+ props: {
40
+ tone: { type: TONE4, values: ['info', 'success', 'warning', 'danger'], default: 'info', description: 'Color and icon.' },
41
+ title: { type: 'string', description: 'Bold first line.' },
42
+ size: size('padding, icon, text and max width'),
43
+ },
44
+ slots: { default: 'Message text.' },
45
+ notes: ['Presentational only: it has no timer, close button or positioning. Mount and unmount it yourself.', 'Has role="status".'],
46
+ },
47
+
48
+ Snackbar: {
49
+ description: 'Compact dark bar with a message and one optional action; use for undo and short confirmations at the bottom of the screen.',
50
+ props: {
51
+ tone: { type: "'brand' | 'sky' | 'success' | 'warning' | 'danger' | 'neutral'", values: ['brand', 'sky', 'success', 'warning', 'danger', 'neutral'], default: 'brand', description: 'Accent color; affects the tonal background (and the action color: green for success, red otherwise).' },
52
+ variant: { type: "'solid' | 'tonal'", values: ['solid', 'tonal'], default: 'solid', description: 'Solid inverse background or a tone-tinted one.' },
53
+ action: { type: 'string', description: 'Label of the action button. The button is rendered only when set.' },
54
+ size: size('height, padding and text'),
55
+ },
56
+ slots: { default: 'Message text.' },
57
+ events: { action: 'Called when the action button is clicked.', close: 'Called when the close icon is clicked. The icon is rendered only when this handler is set.' },
58
+ notes: ['Presentational only: no timer or positioning.'],
59
+ },
60
+
61
+ Progress: {
62
+ description: 'Horizontal progress bar with optional label and meta text; use for uploads, quotas and completion.',
63
+ props: {
64
+ value: { type: 'number', default: 0, description: 'Progress from 0 to 100; clamped to this range.' },
65
+ tone: { type: "'success' | 'warning' | 'danger'", values: ['success', 'warning', 'danger'], description: 'Bar color. Omit for the brand color.' },
66
+ label: { type: 'string', description: 'Text above the bar on the left.' },
67
+ meta: { type: 'string', description: 'Monospace text above the bar on the right, e.g. "64%" or "3 / 5".' },
68
+ size: size('bar height and label text'),
69
+ },
70
+ notes: ['Exposes role="progressbar" with aria-valuenow; the label is not linked via aria-labelledby.'],
71
+ },
72
+
73
+ Spinner: {
74
+ description: 'Inline loading indicator; use inside buttons, cards or next to a loading label.',
75
+ props: {
76
+ size: { type: "'xs' | 'sm' | 'md' | 'lg' | 'xl' | number", values: SIZES, default: 'md', description: 'Preset (14, 16, 20, 28, 36 px) or a number of pixels.' },
77
+ },
78
+ notes: ['Has role="status" and aria-label="Loading".'],
79
+ },
80
+
81
+ Skeleton: {
82
+ description: 'Shimmering placeholder for content that is loading; use text lines, circles or rectangles that match the final layout.',
83
+ props: {
84
+ variant: { type: "'text' | 'circle' | 'rect'", values: ['text', 'circle', 'rect'], default: 'text', description: 'Shape of the placeholder.' },
85
+ lines: { type: 'number', default: 1, description: 'Number of stacked lines. The last of several lines is 60% wide.' },
86
+ width: { type: 'string', default: '100%', description: 'CSS width of each line or rect.' },
87
+ height: { type: 'string', description: 'CSS height. Text lines take the height from size.' },
88
+ size: { type: "'xs' | 'sm' | 'md' | 'lg' | 'xl' | number", values: SIZES, default: 'md', description: 'Text line height and gap; for variant "circle" the diameter (24, 32, 40, 56, 72 px) or a number of pixels.' },
89
+ },
90
+ notes: ['Purely visual and not announced to assistive tech; pair it with aria-busy on the container.'],
91
+ },
92
+
93
+ EmptyState: {
94
+ description: 'Centered icon (or image), title and description with optional actions; use when a list, search or section has no content.',
95
+ props: {
96
+ title: { type: 'string', required: true, description: 'Bold heading.' },
97
+ description: { type: 'string', description: 'Supporting text under the title.' },
98
+ icon: { type: 'string', default: 'inbox', description: 'Icon name shown in the tile. Ignored when image is set.' },
99
+ image: { type: 'string', description: 'Image URL shown instead of the icon.' },
100
+ size: size('padding, icon tile and text'),
101
+ },
102
+ slots: { default: 'Actions (buttons) shown under the text.' },
103
+ },
104
+
105
+ ErrorPage: {
106
+ description: 'Full-page error with a large code, title and description; use for 404, 500 and similar screens.',
107
+ props: {
108
+ code: { type: 'string', default: '404', description: 'Large number shown on top.' },
109
+ title: { type: 'string', default: 'Page not found', description: 'Heading.' },
110
+ description: { type: 'string', default: 'The page may have moved. Check the address or go back.', description: 'Supporting text.' },
111
+ },
112
+ slots: { default: 'Actions (buttons) shown under the text.' },
113
+ },
114
+
115
+ StatusPage: {
116
+ description: 'Ready-made full-page status screen (404, 401, maintenance, coming soon) with one action button.',
117
+ props: {
118
+ kind: { type: "'404' | '401' | 'maintenance' | 'soon'", values: ['404', '401', 'maintenance', 'soon'], default: '404', description: 'Preset that sets the code or icon, default title, description and button label.' },
119
+ title: { type: 'string', description: 'Overrides the preset heading.' },
120
+ description: { type: 'string', description: 'Overrides the preset text.' },
121
+ actionLabel: { type: 'string', description: 'Overrides the button text of the 404, 401 and maintenance presets (default "Back to home").' },
122
+ notifyLabel: { type: 'string', default: 'Notify me', description: 'Button text for kind "soon".' },
123
+ emailLabel: { type: 'string', default: 'name@email.com', description: 'Placeholder of the email field for kind "soon".' },
124
+ codeNode: { type: 'VNode', description: 'Replaces the big code or icon. Used by PageStatus for its code slot.' },
125
+ messageNode: { type: 'VNode', description: 'Replaces heading and description. Used by PageStatus for its message slot.' },
126
+ actionNode: { type: 'VNode', description: 'Replaces the button or email form. Used by PageStatus for its action slot.' },
127
+ },
128
+ slots: { default: 'Extra content rendered after the button.' },
129
+ events: { action: 'Called when the main button is clicked (not used for kind "soon").', notify: 'Called with the typed email string when the Notify button of kind "soon" is clicked.' },
130
+ notes: ['The email is not validated: check it in your onNotify handler.'],
131
+ },
132
+
133
+ DataState: {
134
+ description: 'Switches between loading, empty, error, offline and the real content based on one state value; wrap any data-driven block with it.',
135
+ props: {
136
+ state: { type: "'ready' | 'loading' | 'empty' | 'error' | 'offline'", values: ['ready', 'loading', 'empty', 'error', 'offline'], default: 'ready', description: 'Which view to show. "ready" renders the children.' },
137
+ title: { type: 'string', description: 'Overrides the default heading of the non-ready views ("Loading…", "Nothing here yet", "Something went wrong", "No internet").' },
138
+ description: { type: 'string', description: 'Supporting text for empty, error and offline views (offline defaults to "Saved guides still work.").' },
139
+ retryLabel: { type: 'string', description: 'Retry button label for error ("Retry") and offline ("Try again").' },
140
+ },
141
+ slots: { default: 'Content shown when state is "ready".' },
142
+ events: { retry: 'Called when the retry button of the error or offline view is clicked.' },
143
+ },
144
+
145
+ LoadingOverlay: {
146
+ description: 'Wrapper that dims its content and shows a spinner on top while loading; use over cards, tables or forms during a refresh.',
147
+ props: {
148
+ loading: { type: 'boolean', default: false, description: 'Shows the overlay with the spinner.' },
149
+ label: { type: 'string', description: 'Text shown under the spinner.' },
150
+ },
151
+ slots: { default: 'Content covered by the overlay.' },
152
+ },
153
+ };
@@ -0,0 +1,453 @@
1
+ // API metadata for the forms group. Format: see src/meta/README.md
2
+ const SZ = "'xs' | 'sm' | 'md' | 'lg' | 'xl'";
3
+ const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
4
+ const size = (what = 'Control height, font and padding scale.', def = 'md') => ({ type: SZ, values: SIZES, default: def, description: what });
5
+ const label = { type: 'string', forwarded: true, description: 'Label shown above the control.' };
6
+ const hint = { type: 'string', forwarded: true, description: 'Helper text under the control. Hidden while error is set.' };
7
+ const error = { type: 'string', forwarded: true, description: 'Error message under the control. Turns the control red and is announced with role="alert".' };
8
+ const placeholder = (d) => ({ type: 'string', description: d || 'Placeholder text.' });
9
+ const disabled = { type: 'boolean', default: false, description: 'Disables the control.' };
10
+ const optShape = '{ value: string | number; label: string; disabled?: boolean }[]';
11
+ const change = (d) => ({ change: d });
12
+
13
+ export default {
14
+ TextField: {
15
+ description: 'Single-line text input with label, icon, suffix, hint and error. Use for any short free-text or typed value (email, url, number as text).',
16
+ props: {
17
+ modelValue: { type: 'string', model: true, default: '', description: 'Current value.' },
18
+ label, placeholder: placeholder(), hint, error,
19
+ type: { type: 'string', default: 'text', description: 'Native input type: text, email, tel, url, number, date, etc.' },
20
+ icon: { type: 'string', description: 'Leading icon name.' },
21
+ suffix: { type: 'VNode | string', description: 'Trailing content inside the field (unit, button, icon).' },
22
+ variant: { type: "'filled' | 'underlined' | 'solo'", values: ['filled', 'underlined', 'solo'], description: 'Alternative field style. Omit for the default outlined look.' },
23
+ density: { type: "'compact' | 'default'", values: ['compact', 'default'], default: 'default', description: 'compact uses the small control height.' },
24
+ disabled,
25
+ readonly: { type: 'boolean', default: false, description: 'Makes the input read-only.' },
26
+ size: size(),
27
+ },
28
+ events: change('Called with the new string on every keystroke.'),
29
+ notes: ['Wrapped in a label, so clicking the label focuses the input.'],
30
+ },
31
+ Textarea: {
32
+ description: 'Multi-line text input. Use for comments, descriptions and other long text.',
33
+ props: {
34
+ modelValue: { type: 'string', model: true, default: '', description: 'Current value.' },
35
+ label, placeholder: placeholder(), hint, error, disabled,
36
+ rows: { type: 'number', default: 4, description: 'Visible text rows.' },
37
+ maxlength: { type: 'number', description: 'Maximum number of characters.' },
38
+ size: size(),
39
+ },
40
+ events: change('Called with the new string on every keystroke.'),
41
+ },
42
+ NumberInput: {
43
+ description: 'Numeric stepper with minus and plus buttons and a typed value. Use for quantities, guest counts and small integers.',
44
+ props: {
45
+ modelValue: { type: 'number | null', model: true, description: 'Current number, or null/undefined when the field is empty.' },
46
+ placeholder: placeholder('Shown while the field is empty.'),
47
+ min: { type: 'number', description: 'Lower bound. Default is unbounded.' },
48
+ max: { type: 'number', description: 'Upper bound. Default is unbounded.' },
49
+ step: { type: 'number', default: 1, description: 'Amount added or subtracted by the buttons.' },
50
+ label, hint, error,
51
+ size: size(),
52
+ },
53
+ events: change('Called with the new number, or null when the field is cleared. Typed values are emitted as-is and clamped to min and max on blur; the buttons clamp immediately. Text that is not a number is not emitted and reverts to the last valid value on blur.'),
54
+ notes: ['The field can be fully cleared (null); the + and - buttons start from 0 when empty.', 'Decimal separators . and , are both accepted.'],
55
+ },
56
+ PasswordInput: {
57
+ description: 'Password field with a show/hide toggle. Use for sign-in and password change forms.',
58
+ props: {
59
+ modelValue: { type: 'string', model: true, default: '', description: 'Current password.' },
60
+ label, placeholder: placeholder(), hint, error,
61
+ autocomplete: { type: 'string', default: 'current-password', description: 'Native autocomplete token. Use "new-password" on sign-up and password-change forms so managers offer to generate one.' },
62
+ size: size(),
63
+ },
64
+ events: change('Called with the new string on every keystroke.'),
65
+ notes: ['autocomplete defaults to "current-password"; pass "new-password" for sign-up forms.'],
66
+ },
67
+ SearchInput: {
68
+ description: 'Compact search field with a clear button and an Enter-to-search event. Use above lists and tables.',
69
+ props: {
70
+ modelValue: { type: 'string', model: true, default: '', description: 'Current query.' },
71
+ placeholder: placeholder('Placeholder text. Defaults to "Search".'),
72
+ size: size('Field height and font size.'),
73
+ },
74
+ events: { change: 'Called with the new query on every keystroke and with "" when cleared.', search: 'Called with the current query when Enter is pressed.' },
75
+ notes: ['Filtering is up to you: react to change for live search or to search for submit-on-enter.'],
76
+ },
77
+ PhoneInput: {
78
+ description: 'Phone field with a country code dropdown. Use for contact numbers where the dial code matters.',
79
+ props: {
80
+ modelValue: { type: '{ code?: string; number?: string }', model: true, default: '{}', description: 'Object with the dial code (e.g. "+998") and the national number as typed.' },
81
+ countries: { type: optShape, default: "[{ value: '+966', label: 'SA +966' }, { value: '+998', label: 'UZ +998' }, { value: '+7', label: 'RU +7' }, { value: '+90', label: 'TR +90' }, { value: '+20', label: 'EG +20' }]", description: 'Dial code options. value is the code, label is what the dropdown shows.' },
82
+ label,
83
+ placeholder: placeholder('Placeholder of the number field. Defaults to "90 123 45 67".'),
84
+ hint,
85
+ error,
86
+ size: size(),
87
+ },
88
+ events: change('Called with a new { code, number } object when either part changes.'),
89
+ notes: ['When code is missing the first entry of countries is shown but not written to the model until the user picks one.'],
90
+ },
91
+ CodeInput: {
92
+ description: 'One-time code input rendered as separate digit cells. Use for SMS or email verification codes.',
93
+ props: {
94
+ modelValue: { type: 'string', model: true, default: '', description: 'Digits entered so far.' },
95
+ length: { type: 'number', default: 6, description: 'Number of cells and maximum digits.' },
96
+ label: { type: 'string', default: 'Code', description: 'Accessible name of the hidden input (not rendered visibly).' },
97
+ size: size('Cell size.'),
98
+ },
99
+ events: { ...change('Called with the digits only (non-digits stripped), at most length characters.'), complete: 'Called once with the full code when the last cell gets filled (also on paste).' },
100
+ notes: ['Sets autocomplete="one-time-code" so mobile keyboards offer the SMS code.'],
101
+ },
102
+ ChipInput: {
103
+ description: 'Input that turns typed text into removable chips. Use for tags, keywords and email lists.',
104
+ props: {
105
+ modelValue: { type: 'string[]', model: true, default: '[]', description: 'Current chips.' },
106
+ maxItems: { type: 'number', description: 'Maximum number of chips. Extra entries are ignored.' },
107
+ label, placeholder: placeholder('Placeholder, shown only while there are no chips.'), hint, error,
108
+ size: size(),
109
+ },
110
+ events: change('Called with the new array when a chip is added or removed.'),
111
+ notes: ['Enter or comma adds a chip, Backspace on empty input removes the last one. Duplicates are ignored.'],
112
+ },
113
+ InlineEdit: {
114
+ description: 'Text that turns into an input on click. Use for renaming and quick edits without a form.',
115
+ props: {
116
+ modelValue: { type: 'string', model: true, default: '', description: 'Current text.' },
117
+ emptyText: { type: 'string', default: 'Click to edit', description: 'Shown when the value is empty.' },
118
+ size: size(),
119
+ },
120
+ events: change('Called with the edited text on Enter or on the save button. Escape cancels.'),
121
+ },
122
+ RichEditor: {
123
+ description: 'Rich text editor with a formatting toolbar, built on contenteditable. Use for formatted descriptions and notes.',
124
+ props: {
125
+ modelValue: { type: 'string', model: true, default: '', description: 'HTML string.' },
126
+ label: { type: 'string', default: 'Editor', description: 'Label above the editor. Also the accessible name.' },
127
+ placeholder: placeholder('Placeholder shown when empty. Defaults to "Write something…".'),
128
+ sanitize: { type: 'boolean', default: false, description: 'Strip script, style, iframe, object, embed and similar tags plus on* and javascript: attributes from the emitted HTML, using a small allowlist sanitizer.' },
129
+ size: size(),
130
+ },
131
+ events: change('Called with the editor innerHTML on every edit or toolbar action (sanitized when sanitize is set).'),
132
+ notes: ['The editor only rewrites its content when modelValue differs from what is already in the DOM, so the caret is kept while typing.', 'sanitize is a best-effort allowlist, not a full XSS guarantee: sanitize on the server too before storing or rendering the HTML elsewhere.', 'Toolbar: bold, italic, underline, strikethrough, alignment, lists, quote and code block.'],
133
+ },
134
+ Select: {
135
+ description: 'Single-choice dropdown. Use when the user picks one value from a known list.',
136
+ props: {
137
+ modelValue: { type: 'string | number', model: true, description: 'Value of the selected option.' },
138
+ options: { type: optShape, required: true, default: '[]', description: 'Available options. An option with disabled: true is dimmed and cannot be chosen.' },
139
+ label, placeholder: placeholder('Shown when nothing is selected. Defaults to "—".'),
140
+ disabled,
141
+ size: size(),
142
+ },
143
+ events: change('Called with the value of the chosen option.'),
144
+ notes: ['The popup closes on outside click and on Escape (focus returns to the trigger).'],
145
+ },
146
+ MultiSelect: {
147
+ description: 'Dropdown with checkboxes for choosing several values. Use for filters and multi-valued fields.',
148
+ props: {
149
+ modelValue: { type: '(string | number)[]', model: true, default: '[]', description: 'Values of the selected options.' },
150
+ options: { type: optShape, default: '[]', description: 'Available options. An option with disabled: true cannot be toggled.' },
151
+ label, placeholder: placeholder('Shown when nothing is selected. Defaults to "—".'),
152
+ disabled,
153
+ size: size(),
154
+ },
155
+ events: change('Called with the new array of values after toggling an option.'),
156
+ notes: ['The trigger shows "N selected"; the list stays open while picking and closes on outside click or Escape.'],
157
+ },
158
+ GroupedSelect: {
159
+ description: 'Single-choice dropdown with option groups under headings. Use for long lists that have natural categories.',
160
+ props: {
161
+ modelValue: { type: 'string | number', model: true, description: 'Value of the selected option.' },
162
+ groups: { type: '{ label: string; options: { value: string | number; label: string; disabled?: boolean }[] }[]', default: '[]', description: 'Groups with a heading and their options. Option values must be unique across groups. An option with disabled: true cannot be chosen.' },
163
+ label, placeholder: placeholder('Shown when nothing is selected. Defaults to "—".'),
164
+ disabled,
165
+ size: size(),
166
+ },
167
+ events: change('Called with the value of the chosen option.'),
168
+ notes: ['The popup closes on outside click and on Escape.'],
169
+ },
170
+ Combobox: {
171
+ description: 'Searchable single-choice dropdown. Use for long option lists where typing to filter is faster than scrolling.',
172
+ props: {
173
+ modelValue: { type: 'string | number', model: true, description: 'Value of the selected option.' },
174
+ options: { type: optShape, default: '[]', description: 'Available options. Filtering is a case-insensitive match on label. An option with disabled: true cannot be chosen.' },
175
+ label, hint, error,
176
+ placeholder: placeholder('Defaults to "Search…".'),
177
+ emptyText: { type: 'string', default: '—', description: 'Shown when no option matches the query.' },
178
+ disabled,
179
+ size: size(),
180
+ },
181
+ events: change('Called with the value of the chosen option.'),
182
+ notes: ['Selecting from the list is the only way to set a value: free text is never emitted.', 'The list closes on outside click and on Escape.'],
183
+ },
184
+ TreeSelect: {
185
+ description: 'Dropdown with a searchable tree. Use for hierarchical choices such as regions, categories or org units.',
186
+ props: {
187
+ modelValue: { type: 'string | number', model: true, description: 'id of the selected node.' },
188
+ nodes: { type: '{ id: string | number; label: string; children?: Node[]; disabled?: boolean }[]', default: '[]', description: 'Tree nodes. A node with disabled: true cannot be clicked. Top level is expanded by default.' },
189
+ selectGroups: { type: 'boolean', default: false, description: 'Allow selecting nodes that have children. By default clicking a parent only expands it.' },
190
+ label, placeholder: placeholder('Shown when nothing is selected. Defaults to "—".'),
191
+ disabled,
192
+ size: size(),
193
+ },
194
+ events: change('Called with the id of the chosen node.'),
195
+ notes: ['Typing in the search box expands all matching branches.', 'The popup closes on outside click and on Escape.'],
196
+ },
197
+ PillSelect: {
198
+ description: 'Small pill-shaped dropdown button. Use for inline filters and sort controls in toolbars.',
199
+ props: {
200
+ modelValue: { type: 'string | number', model: true, description: 'Value of the selected option.' },
201
+ value: { type: 'string | number', description: 'Alias of modelValue, used when modelValue is not set.' },
202
+ options: { type: optShape, default: '[]', description: 'Available options. A missing list renders an empty menu. An option with disabled: true cannot be chosen.' },
203
+ placeholder: { type: 'string', description: 'Shown when value matches no option.' },
204
+ disabled,
205
+ size: size('Pill height and font size.'),
206
+ },
207
+ events: change('Called with the value of the chosen option.'),
208
+ notes: ['The menu closes on outside click and on Escape.'],
209
+ },
210
+ Checkbox: {
211
+ description: 'Boolean checkbox with an inline label. Use for opt-ins and independent yes/no choices.',
212
+ props: {
213
+ modelValue: { type: 'boolean', model: true, default: false, description: 'Checked state.' },
214
+ label: { type: 'string', description: 'Text next to the box.' },
215
+ disabled,
216
+ size: size('Box and label size.'),
217
+ },
218
+ events: change('Called with the toggled boolean.'),
219
+ notes: ['Rendered as a button with role="checkbox". When disabled it is dimmed, shows a not-allowed cursor and ignores clicks.'],
220
+ },
221
+ Switch: {
222
+ description: 'On/off toggle with an inline label. Use for settings that apply immediately.',
223
+ props: {
224
+ modelValue: { type: 'boolean', model: true, default: false, description: 'On state.' },
225
+ label: { type: 'string', description: 'Text next to the toggle.' },
226
+ disabled,
227
+ size: size('Track and label size.'),
228
+ },
229
+ events: change('Called with the toggled boolean.'),
230
+ notes: ['Rendered as a button with role="switch". When disabled it is dimmed, shows a not-allowed cursor and ignores clicks.'],
231
+ },
232
+ RadioGroup: {
233
+ description: 'Group of radio options with optional hints. Use for a short single-choice list that should stay visible.',
234
+ props: {
235
+ modelValue: { type: 'string | number', model: true, description: 'Value of the selected option.' },
236
+ options: { type: '{ value: string | number; label: string; hint?: string; disabled?: boolean }[]', default: '[]', description: 'Radio options. hint is a second line under the label; disabled: true disables that option only.' },
237
+ label,
238
+ disabled,
239
+ size: size(),
240
+ },
241
+ events: change('Called with the value of the chosen option.'),
242
+ },
243
+ OptionCard: {
244
+ description: 'Selectable card with title, description, price and optional image or icon. Usually rendered by OptionCards.',
245
+ props: {
246
+ title: { type: 'string', description: 'Main text.' },
247
+ desc: { type: 'string', description: 'Secondary text under the title.' },
248
+ price: { type: 'string', description: 'Right-aligned price or value text.' },
249
+ image: { type: 'string', description: 'Image URL shown on the left. Takes priority over icon.' },
250
+ icon: { type: 'string', description: 'Icon name shown in a tile on the left.' },
251
+ checked: { type: 'boolean', default: false, description: 'Selected state.' },
252
+ multiple: { type: 'boolean', default: false, description: 'Checkbox look and role instead of radio.' },
253
+ size: size(),
254
+ },
255
+ events: { click: 'Called when the card is pressed. Toggle checked yourself.' },
256
+ },
257
+ OptionCards: {
258
+ description: 'Grid of selectable OptionCards, single or multiple choice. Use for plans, room types and delivery options.',
259
+ props: {
260
+ modelValue: { type: 'string | number | (string | number)[]', model: true, description: 'Selected value, or an array of values when multiple is true.' },
261
+ options: { type: '{ value: string | number; title: string; desc?: string; price?: string; image?: string; icon?: string }[]', default: '[]', description: 'Cards to show. Fields are passed to OptionCard.' },
262
+ multiple: { type: 'boolean', default: false, description: 'Allow several selected cards. modelValue becomes an array.' },
263
+ min: { type: 'number', default: 220, description: 'Minimum card width in px before the grid wraps.' },
264
+ label,
265
+ size: size(),
266
+ },
267
+ events: change('Called with the value (single) or the new array of values (multiple).'),
268
+ },
269
+ Slider: {
270
+ description: 'Single-handle range slider with a value readout. Use for picking one number in a bounded range.',
271
+ props: {
272
+ modelValue: { type: 'number', model: true, description: 'Current value. Defaults to min.' },
273
+ min: { type: 'number', default: 0, description: 'Lowest value.' },
274
+ max: { type: 'number', default: 100, description: 'Highest value.' },
275
+ step: { type: 'number', default: 1, description: 'Step between values.' },
276
+ showValue: { type: 'boolean', default: true, description: 'Show the current value to the right of the track.' },
277
+ formatValue: { type: '(value: number) => string', description: 'Formats the readout, e.g. v => v + " $".' },
278
+ label, hint, error,
279
+ size: size('Track and thumb size.'),
280
+ },
281
+ events: change('Called with the new number while dragging.'),
282
+ },
283
+ RangeSlider: {
284
+ description: 'Two-handle slider for a min-max range. Use for price, duration or date-number filters.',
285
+ props: {
286
+ modelValue: { type: '[number, number]', model: true, description: 'Tuple [from, to]. Defaults to [min, max].' },
287
+ min: { type: 'number', default: 0, description: 'Lowest value.' },
288
+ max: { type: 'number', default: 100, description: 'Highest value.' },
289
+ step: { type: 'number', default: 1, description: 'Step between values.' },
290
+ formatValue: { type: '(value: number) => string', description: 'Formats both ends in the readout.' },
291
+ label,
292
+ size: size('Track and thumb size.'),
293
+ },
294
+ events: change('Called with a new [from, to] tuple; the handles cannot cross.'),
295
+ },
296
+ Rating: {
297
+ description: 'Star rating input. Use for reviews and quick feedback.',
298
+ props: {
299
+ modelValue: { type: 'number', model: true, default: 0, description: 'Number of filled stars.' },
300
+ max: { type: 'number', default: 5, description: 'Number of stars.' },
301
+ readonly: { type: 'boolean', default: false, description: 'Disables the stars, for display only.' },
302
+ label: { type: 'string', default: 'Rating', description: 'Accessible name of the group (not rendered visibly).' },
303
+ size: size('Star size: 14, 18, 20, 26 or 32 px. A number is used as the pixel size directly.'),
304
+ },
305
+ events: change('Called with the clicked star count (1..max). Clicking the current value does not reset it.'),
306
+ },
307
+ ColorPicker: {
308
+ description: 'Swatch row for choosing a color from presets. Use for tags, labels and theme accents.',
309
+ props: {
310
+ modelValue: { type: 'string', model: true, description: 'Selected color, same string as in presets.' },
311
+ presets: { type: 'string[]', default: "['#BC926A', '#5FAACC', '#3FD13F', '#E26E5F', '#E8B964', '#A98BD6', '#1B1919']", description: 'CSS colors offered as swatches.' },
312
+ label,
313
+ size: size('Swatch size.'),
314
+ },
315
+ events: change('Called with the clicked color string.'),
316
+ notes: ['Preset-only: there is no free color input.'],
317
+ },
318
+ Calendar: {
319
+ description: 'Month grid for picking one date. Use inline, or inside a popover via DatePicker.',
320
+ props: {
321
+ modelValue: { type: 'Date | string | number', model: true, description: 'Selected date: a Date, ISO string or timestamp. Also sets the visible month. A date-only string like "2026-10-21" is read as a local date, so the day does not shift with the time zone.' },
322
+ locale: { type: 'string', default: 'en-GB', description: 'BCP 47 locale for month and weekday names.' },
323
+ weekStartsOn: { type: 'number', default: 1, description: 'First day of the week: 0 = Sunday, 1 = Monday.' },
324
+ events: { type: 'Record<string, string>', default: '{}', description: 'Marked days: keys are YYYY-MM-DD, values are the tooltip text.' },
325
+ size: size('Day cell and header size.'),
326
+ },
327
+ events: change('Called with a Date object (local time) for the clicked day.'),
328
+ notes: ['The event payload is a Date, not a string: convert to ISO yourself if the model is a string.'],
329
+ },
330
+ RangeCalendar: {
331
+ description: 'Month grid for picking a date range with hover preview. Use for stays, trips and report periods.',
332
+ props: {
333
+ modelValue: { type: '[Date | string | number | null, Date | string | number | null]', model: true, description: 'Controlled range [start, end]; either end may be null. Date-only ISO strings are read as local dates. When omitted the selection is kept internally.' },
334
+ start: { type: 'Date | string | number', description: 'Month to show initially when no range is set. Does not preselect a range.' },
335
+ locale: { type: 'string', default: 'en-GB', description: 'BCP 47 locale for month and weekday names.' },
336
+ weekStartsOn: { type: 'number', default: 1, description: 'First day of the week: 0 = Sunday, 1 = Monday.' },
337
+ size: size('Day cell and header size.'),
338
+ },
339
+ events: change('Called with [start, end] Date objects. In controlled mode (modelValue set) it also fires after the first click with [start, null]; uncontrolled it fires once the second date is picked.'),
340
+ notes: ['Pass modelValue to control the selection; pick a date earlier than the start to restart the range.'],
341
+ },
342
+ DatePicker: {
343
+ description: 'Field with a calendar popup for choosing a date. Use in forms where a single date is needed.',
344
+ props: {
345
+ modelValue: { type: 'string | Date | number', model: true, description: 'Selected date (ISO string recommended, e.g. 2026-10-21).' },
346
+ locale: { type: 'string', default: 'en-GB', description: 'BCP 47 locale used for the trigger text and the calendar.' },
347
+ weekStartsOn: { type: 'number', default: 1, description: 'First day of the week: 0 = Sunday, 1 = Monday.' },
348
+ label, hint, error,
349
+ placeholder: placeholder('Shown when no date is set. Defaults to "Select date".'),
350
+ disabled,
351
+ size: size(),
352
+ },
353
+ events: change('Called with a Date object (local midnight) for the chosen day. The popup closes after picking.'),
354
+ notes: ['The event payload is a Date, not a string: convert to ISO yourself if the model is a string.', 'The popup closes on outside click and on Escape.'],
355
+ },
356
+ TimePicker: {
357
+ description: 'Hour and minute selects for a time of day. Use for opening hours and appointment times.',
358
+ props: {
359
+ modelValue: { type: 'string', model: true, default: '09:00', description: 'Time as "HH:mm" (24 hour).' },
360
+ minuteStep: { type: 'number', default: 15, description: 'Step of the minute options in minutes (1 to 60). 15 gives 00, 15, 30, 45; 5 gives 00, 05, ... 55.' },
361
+ label,
362
+ disabled,
363
+ size: size(),
364
+ },
365
+ events: change('Called with a new "HH:mm" string. Minute options follow minuteStep.'),
366
+ notes: ['A model value outside the minute grid (e.g. 09:07) is added to the minute list so it still displays.', 'Both selects close on outside click and on Escape.'],
367
+ },
368
+ FileUpload: {
369
+ description: 'Drop-zone style file picker. Use for documents and attachments.',
370
+ props: {
371
+ title: { type: 'string', default: 'Drop files here or browse', description: 'Main text in the zone.' },
372
+ hint: { type: 'string', default: 'PDF, JPG up to 10 MB', description: 'Secondary text in the zone. Purely informational: use accept and maxSize to enforce limits.' },
373
+ accept: { type: 'string', description: 'Accepted types as in the native attribute, e.g. ".pdf,image/*". Also applied to dropped files.' },
374
+ maxSize: { type: 'number', description: 'Maximum size of one file in bytes. Larger files are rejected.' },
375
+ multiple: { type: 'boolean', default: false, description: 'Allow selecting several files.' },
376
+ image: { type: 'boolean', default: false, description: 'Use the image icon instead of the upload icon.' },
377
+ label, error,
378
+ size: size(),
379
+ },
380
+ events: { ...change('Called with the array of accepted File objects, from the picker or a drop. Without multiple only the first one is kept.'), reject: 'Called as (file, reason) for each refused file; reason is "type" (not in accept), "size" (over maxSize) or "count" (extra file when multiple is off).' },
381
+ notes: ['Click, Enter or Space opens the picker; files can also be dropped onto the zone, which highlights while dragging.', 'Stateless: the component does not list the chosen files, render them yourself.'],
382
+ },
383
+ ImageUpload: {
384
+ description: 'Image picker with a live preview, square or circular. Use for avatars, covers and logos.',
385
+ props: {
386
+ modelValue: { type: 'string', model: true, description: 'Image URL or data URL to show before the user picks a new file.' },
387
+ shape: { type: "'square' | 'circle'", values: ['square', 'circle'], default: 'square', description: 'circle gives a round avatar-style crop, otherwise rounded corners.' },
388
+ aspect: { type: 'string', default: '1/1', description: 'CSS aspect-ratio of the box, e.g. "16/9".' },
389
+ hint: { type: 'string', default: 'Upload', description: 'Text shown in the empty state.' },
390
+ label,
391
+ size: size('Maximum width: 112, 128, 160, 192 or 224 px. A number is used as px directly.'),
392
+ },
393
+ events: change('Called with (dataUrl, file): the picked image as a data URL and the original File.'),
394
+ notes: ['The preview is kept in internal state, so it updates even without updating modelValue.'],
395
+ },
396
+ FormField: {
397
+ description: 'Label, required marker, hint and error wrapper around any control. Use to build a field from custom inputs.',
398
+ props: {
399
+ label,
400
+ required: { type: 'boolean', default: false, description: 'Appends a red asterisk to the label. Does not validate.' },
401
+ hint, error,
402
+ size: size('Only marks the wrapper with data-size; sizes never leak into children by design, so set size on the inner control too.'),
403
+ },
404
+ slots: { default: 'The control(s) to wrap.' },
405
+ notes: ['Does not link label and control by id: put the control inside a label or pass an aria-label yourself.'],
406
+ },
407
+ ValidatedField: {
408
+ description: 'TextField (or PasswordInput) with built-in validation rules. Use for simple client-side validation without a form library.',
409
+ props: {
410
+ modelValue: { type: 'string', model: true, default: '', description: 'Current value.' },
411
+ rules: { type: '((value: string) => true | string)[]', default: '[]', description: 'Validators run in order; return true when valid or an error message. The first failure is shown.' },
412
+ type: { type: "'text' | 'password' | string", default: 'text', description: '"password" renders PasswordInput, any other value is passed to TextField as the input type.' },
413
+ error: { type: 'string', description: 'External error. Takes priority over rules.' },
414
+ label: { ...label, forwarded: true }, placeholder: { ...placeholder(), forwarded: true }, hint: { ...hint, forwarded: true },
415
+ icon: { type: 'string', description: 'Leading icon name (TextField only).', forwarded: true },
416
+ disabled: { ...disabled, forwarded: true },
417
+ size: size(),
418
+ },
419
+ events: change('Called with the new string on every keystroke.'),
420
+ notes: ['Rules start running after the first edit, so an untouched field shows no error.', 'All other props are forwarded to TextField / PasswordInput.'],
421
+ },
422
+ LoginForm: {
423
+ description: 'Ready sign-in form with login, password, remember-me, forgot link and submit button. Use as the body of an auth page.',
424
+ props: {
425
+ error: { type: 'string', description: 'Failure message shown in a danger alert above the fields.' },
426
+ loading: { type: 'boolean', default: false, description: 'Disables the submit button and shows a spinner.' },
427
+ showForgot: { type: 'boolean', default: true, description: 'Show the "Forgot password?" link.' },
428
+ submitLabel: { type: 'string', default: 'Sign in', description: 'Submit button text. Wins over labels.submit.' },
429
+ labels: { type: '{ email?: string; password?: string; remember?: string; forgot?: string; submit?: string }', default: "{ email: 'Email or phone', password: 'Password', remember: 'Remember device', forgot: 'Forgot password?', submit: 'Sign in' }", description: 'Texts of the form; any missing key keeps its English default.' },
430
+ size: size(),
431
+ },
432
+ events: { submit: 'Called with { email, password, remember } when the form is submitted.', forgot: 'Called when "Forgot password?" is clicked.' },
433
+ notes: ['Field values live inside the component: read them from the submit payload.', 'Translate the texts with labels; the "email" value holds an email or phone.'],
434
+ },
435
+ StepForm: {
436
+ description: 'Multi-step form shell: stepper, current step content and Back/Next buttons. Use for wizards and onboarding.',
437
+ props: {
438
+ steps: { type: '{ label: string; content: VNode }[]', default: '[]', description: 'Steps in order. content is rendered while the step is active.' },
439
+ finishLabel: { type: 'string', default: 'Finish', description: 'Text of the Next button on the last step.' },
440
+ size: size('Also sets the size of the Back/Next buttons.'),
441
+ },
442
+ events: { finish: 'Called when Next is pressed on the last step.' },
443
+ notes: ['The current step is internal; Next never validates, so guard the content yourself.'],
444
+ },
445
+ Stepper: {
446
+ description: 'Horizontal progress indicator with numbered steps. Use to show position in a multi-step flow.',
447
+ props: {
448
+ steps: { type: 'string[]', default: '[]', description: 'Step labels in order.' },
449
+ current: { type: 'number', required: true, description: 'Zero-based index of the active step. Earlier steps show a check mark.' },
450
+ size: size('Step circle and label size.'),
451
+ },
452
+ },
453
+ };
@@ -0,0 +1,16 @@
1
+ // Component groups: single source for the docs site, manifest and llms.txt.
2
+ export const GROUPS = [
3
+ { id: 'typography', title: 'Typography', blurb: 'Headings and text.', names: ['Heading', 'Text', 'Code', 'Kbd', 'Link'] },
4
+ { id: 'layout', title: 'Layout', blurb: 'Stacks, grids and page structure.', names: ['Box', 'Center', 'HStack', 'VStack', 'Grid', 'Divider', 'ScrollArea', 'SplitLayout', 'AppShell', 'PageHeader', 'AuthLayout', 'SettingsSection', 'LiquidGlass', 'Footer', 'Sortable', 'Portal'] },
5
+ { id: 'actions', title: 'Actions', blurb: 'Buttons and things you press.', names: ['Button', 'IconButton', 'Pressable', 'Fab', 'CopyButton', 'ToggleGroup', 'SegmentedControl', 'FilterChips', 'Chip', 'Tag', 'Badge', 'ActionRail', 'ActionCard', 'NotificationBell'] },
6
+ { id: 'forms', title: 'Forms', blurb: 'Inputs, pickers and form building blocks.', names: ['TextField', 'Textarea', 'NumberInput', 'PasswordInput', 'SearchInput', 'PhoneInput', 'CodeInput', 'ChipInput', 'InlineEdit', 'RichEditor', 'Select', 'MultiSelect', 'GroupedSelect', 'Combobox', 'TreeSelect', 'PillSelect', 'Checkbox', 'Switch', 'RadioGroup', 'OptionCard', 'OptionCards', 'Slider', 'RangeSlider', 'Rating', 'ColorPicker', 'Calendar', 'RangeCalendar', 'DatePicker', 'TimePicker', 'FileUpload', 'ImageUpload', 'FormField', 'ValidatedField', 'LoginForm', 'StepForm', 'Stepper'] },
7
+ { id: 'data', title: 'Data display', blurb: 'Tables, stats, charts and lists.', names: ['Card', 'DataTable', 'DataGroup', 'Descriptions', 'Timeline', 'TreeView', 'CalendarView', 'KpiTile', 'KpiInline', 'StatsCard', 'StatRow', 'StatLine', 'StatStrip', 'CountryRow', 'VersusBar', 'SegBar', 'MiniChart', 'ChartCard', 'Ring', 'OccupancyChart', 'SalesFunnel', 'SystemStatus', 'UserCell', 'IconTile', 'StatusIcon', 'HeroBanner', 'TabTiles', 'PricingCard', 'IntegrationCard'] },
8
+ { id: 'feedback', title: 'Feedback', blurb: 'Alerts, progress and states.', names: ['Alert', 'Banner', 'Toast', 'Snackbar', 'Progress', 'Spinner', 'Skeleton', 'EmptyState', 'ErrorPage', 'StatusPage', 'DataState', 'LoadingOverlay'] },
9
+ { id: 'overlay', title: 'Overlay', blurb: 'Dialogs, sheets and popovers.', names: ['Modal', 'Drawer', 'ConfirmDialog', 'Popconfirm', 'StepDialog', 'FullscreenDialog', 'BottomSheet', 'Actionsheet', 'Popover', 'HoverCard', 'Tooltip', 'Dropdown', 'Menu', 'ContextMenu', 'CommandBar', 'CommandPalette', 'ImageViewer', 'Tour', 'TourPopover', 'Screensaver'] },
10
+ { id: 'navigation', title: 'Navigation', blurb: 'Menus, tabs and paging.', names: ['TopNav', 'SideNav', 'SubNav', 'Tabs', 'PillTabs', 'Breadcrumbs', 'Pagination', 'Accordion'] },
11
+ { id: 'media', title: 'Media and icons', blurb: 'Avatars, images, maps and icons.', names: ['Avatar', 'Icon', 'LazyImage', 'Gallery', 'Carousel', 'Map', 'ProfileHeader', 'ChatAi'] },
12
+ { id: 'domain', title: 'Domain', blurb: 'Umrah, Hajj, hotel and CRM blocks.', names: ['HotelCard', 'TourCard', 'PrayerTimes', 'RamadanTracker', 'BookingCard', 'GuestCard', 'RoomGrid', 'RoomCalendar', 'LeadCard', 'DealCard', 'PipelineBoard', 'ContactPanel', 'KanbanColumn', 'KanbanCard', 'CallRow', 'CallCard', 'IncomingCall', 'AuthSplit'] },
13
+ { id: 'pages', title: 'Page templates', blurb: 'Ready layouts with named slots.', names: ['PageDashboard', 'PagePatterns', 'PageCrm', 'PageBookings', 'PageCatalog', 'PageDetail', 'PageSettings', 'PageUserList', 'PageProfile', 'PageForm', 'PageOrder', 'PageAccount', 'PagePricing', 'PageFaq', 'PageAuth', 'PageStatus'] },
14
+ ];
15
+
16
+ export const groupOf = name => GROUPS.find(g => g.names.includes(name));