@talelk/newui-organisms 0.0.0-stage → 2.0.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 (64) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +89 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataState/DataState.test.tsx +45 -0
  17. package/src/organisms/DataState/DataState.tsx +111 -0
  18. package/src/organisms/DataState/index.ts +6 -0
  19. package/src/organisms/DataTable/DataTable.test.tsx +161 -0
  20. package/src/organisms/DataTable/DataTable.tsx +612 -0
  21. package/src/organisms/DataTable/index.ts +1 -0
  22. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  23. package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
  24. package/src/organisms/DetailHeader/index.ts +1 -0
  25. package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
  26. package/src/organisms/FilterBar/FilterBar.tsx +251 -0
  27. package/src/organisms/FilterBar/index.ts +1 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  29. package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
  30. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  31. package/src/organisms/KanbanBoard/drag.ts +407 -0
  32. package/src/organisms/KanbanBoard/index.ts +17 -0
  33. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  34. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  35. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  36. package/src/organisms/MobileNav/index.ts +6 -0
  37. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  38. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  39. package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
  40. package/src/organisms/PageToolbar/index.ts +1 -0
  41. package/src/organisms/Section/Section.test.tsx +41 -0
  42. package/src/organisms/Section/Section.tsx +108 -0
  43. package/src/organisms/Section/index.ts +1 -0
  44. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  45. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  46. package/src/organisms/SelectionBar/index.ts +5 -0
  47. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  48. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  49. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  50. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  51. package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
  52. package/src/organisms/SettingsDialog/index.ts +20 -0
  53. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  54. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  55. package/src/organisms/Sidebar/Sidebar.tsx +732 -0
  56. package/src/organisms/Sidebar/index.ts +22 -0
  57. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  58. package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
  59. package/src/organisms/StatGrid/StatGrid.tsx +151 -0
  60. package/src/organisms/StatGrid/index.ts +1 -0
  61. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  62. package/src/organisms/VirtualList/VirtualList.tsx +113 -0
  63. package/src/organisms/VirtualList/index.ts +1 -0
  64. package/src/organisms/index.ts +16 -0
@@ -0,0 +1,407 @@
1
+ import { type Accessor, createMemo, createSignal, onCleanup } from 'solid-js'
2
+
3
+ /**
4
+ * The board's drag gesture.
5
+ *
6
+ * This is not a state machine and there is no Zag machine to reach for: a
7
+ * sortable board is pointer geometry — where the finger is, what it is over, and
8
+ * what has to move as a result. So the whole gesture lives here, behind a
9
+ * factory, with the DOM knowledge (`data-kanban-card`, `data-dragging`) kept in
10
+ * the component and the arithmetic kept in this file.
11
+ *
12
+ * The shape is the reference implementation's, because each piece of it was
13
+ * earned:
14
+ *
15
+ * - **A press is not a drag yet.** A mouse has to travel `MOUSE_THRESHOLD`;
16
+ * a touch has to hold still for `LIFT_DELAY`, and *stops being a candidate* if
17
+ * it drifts past `DRIFT_TOLERANCE`, because a finger that moves is scrolling
18
+ * the board, not picking up a card.
19
+ * - **One rAF loop, not `pointermove`.** A held pointer at the edge keeps
20
+ * auto-scrolling the containers under itself, which moves the slots the
21
+ * pointer is not moving over. Position, hit-test and auto-scroll therefore all
22
+ * run per frame.
23
+ * - **The insertion index counts the *other* cards only.** The dragged card's
24
+ * slot is skipped, which is what makes the choice self-stabilising: inserting
25
+ * pushes the boundary card away from the pointer, so it cannot oscillate
26
+ * between two neighbours.
27
+ * - **Live reordering is a preview, not a mutation.** The reference splices the
28
+ * real array during the drag and splices it back on cancel. Here the drop is
29
+ * reported once, at the end: cancelling is then just discarding a signal,
30
+ * there is no restore path to get wrong, and a controlled board never sees a
31
+ * move it did not accept.
32
+ */
33
+
34
+ /** px a mouse must travel before a press becomes a drag. */
35
+ export const MOUSE_THRESHOLD = 4
36
+ /** px a *held touch* may drift before the gesture is read as a scroll. */
37
+ export const DRIFT_TOLERANCE = 8
38
+ /** ms a touch must hold before it becomes a drag. */
39
+ export const LIFT_DELAY = 250
40
+ /** px-wide edge zones that trigger auto-scroll. */
41
+ export const EDGE = 48
42
+ /** px per frame at the very edge. */
43
+ export const EDGE_SPEED = 14
44
+ /** ms the ghost glides onto the slot on release. */
45
+ export const SETTLE_MS = 150
46
+
47
+ export interface KanbanSlot {
48
+ column: string
49
+ /** Insertion index counted over the column's *other* items. */
50
+ index: number
51
+ }
52
+
53
+ export interface KanbanDragState<T> {
54
+ item: T
55
+ itemId: string
56
+ /** The grabbed card's size, so the ghost and the slot match it exactly. */
57
+ width: number
58
+ height: number
59
+ /** Ghost top-left, in viewport coordinates. */
60
+ x: number
61
+ y: number
62
+ /** True while the ghost glides onto the slot; the ghost animates only then. */
63
+ settling: boolean
64
+ }
65
+
66
+ /**
67
+ * A press that starts on one of these is a click on that control — a menu, a
68
+ * link, a field — not a grab. `data-kanban-no-drag` is the escape hatch for
69
+ * anything else interactive a card contains.
70
+ */
71
+ const INTERACTIVE = 'button, a, input, select, textarea, [data-kanban-no-drag]'
72
+
73
+ export interface KanbanDragOptions<T extends { id: string }> {
74
+ /** The element that scrolls horizontally — the board's viewport. */
75
+ container: () => HTMLElement | undefined
76
+ /** The element that scrolls vertically, per column. */
77
+ columnScroller: (columnId: string) => HTMLElement | undefined
78
+ /** Column ids in render order. */
79
+ columnIds: () => string[]
80
+ /** Where the item sits right now: the live preview, else its slot in the data. */
81
+ slotOf: (itemId: string) => KanbanSlot | undefined
82
+ /** The pointer crossed into another slot. The board re-renders from this. */
83
+ onPreview: (itemId: string, to: KanbanSlot) => void
84
+ /** Released somewhere new. `from` is where the gesture started. */
85
+ onDrop: (item: T, from: KanbanSlot, to: KanbanSlot) => void
86
+ /** Released where it started, or cancelled. */
87
+ onCancel: () => void
88
+ }
89
+
90
+ export interface KanbanDrag<T> {
91
+ state: Accessor<KanbanDragState<T> | undefined>
92
+ /** Begin tracking a press on a card. */
93
+ start: (event: PointerEvent, item: T, card: HTMLElement) => void
94
+ isDragging: (itemId: string) => boolean
95
+ /** Abandon the gesture, putting the card back where it was. */
96
+ cancel: () => void
97
+ }
98
+
99
+ interface Pending<T> {
100
+ item: T
101
+ card: HTMLElement
102
+ startX: number
103
+ startY: number
104
+ mouse: boolean
105
+ timer: number
106
+ }
107
+
108
+ export function createKanbanDrag<T extends { id: string }>(
109
+ options: KanbanDragOptions<T>,
110
+ ): KanbanDrag<T> {
111
+ /**
112
+ * Two signals, not one: the position changes every frame, and the cards read
113
+ * only *which* item is lifted. One signal would re-render every card in the
114
+ * board sixty times a second to move one ghost.
115
+ */
116
+ const [lifted, setLifted] = createSignal<Omit<
117
+ KanbanDragState<T>,
118
+ 'x' | 'y' | 'settling'
119
+ > | null>(null)
120
+ const [ghost, setGhost] = createSignal<{
121
+ x: number
122
+ y: number
123
+ settling: boolean
124
+ } | null>(null)
125
+
126
+ const state = createMemo<KanbanDragState<T> | undefined>(() => {
127
+ const active = lifted()
128
+ const position = ghost()
129
+ return active && position ? { ...active, ...position } : undefined
130
+ })
131
+
132
+ const pointer = { x: 0, y: 0 }
133
+ const grab = { x: 0, y: 0 }
134
+ let pending: Pending<T> | undefined
135
+ let from: KanbanSlot | undefined
136
+ let frame = 0
137
+ let settle = 0
138
+ // Whether the window listeners are on. Without this, teardown after a
139
+ // cancelled press removes listeners that are already gone, and "we remove
140
+ // exactly what we added" stops being true.
141
+ let watching = false
142
+ let holding = false
143
+
144
+ const motionAllowed = () =>
145
+ typeof matchMedia !== 'function' ||
146
+ !matchMedia('(prefers-reduced-motion: reduce)').matches
147
+
148
+ function preventScroll(event: TouchEvent) {
149
+ // While a card is lifted, the gesture is ours: the only way to stop the
150
+ // browser from starting a scroll (which would fire `pointercancel` and kill
151
+ // the drag) is a non-passive listener on `touchmove`.
152
+ event.preventDefault()
153
+ }
154
+
155
+ function watch() {
156
+ if (watching) return
157
+ watching = true
158
+ window.addEventListener('pointermove', onPointerMove)
159
+ window.addEventListener('pointerup', onPointerUp)
160
+ window.addEventListener('pointercancel', onPointerCancel)
161
+ }
162
+
163
+ function releaseListeners() {
164
+ if (watching) {
165
+ watching = false
166
+ window.removeEventListener('pointermove', onPointerMove)
167
+ window.removeEventListener('pointerup', onPointerUp)
168
+ window.removeEventListener('pointercancel', onPointerCancel)
169
+ }
170
+ if (holding) {
171
+ holding = false
172
+ window.removeEventListener('touchmove', preventScroll)
173
+ }
174
+ }
175
+
176
+ function stopFrame() {
177
+ cancelAnimationFrame(frame)
178
+ frame = 0
179
+ }
180
+
181
+ /** Tear the gesture down. Never reports: this is what unmount does. */
182
+ function teardown() {
183
+ if (pending) {
184
+ clearTimeout(pending.timer)
185
+ pending = undefined
186
+ }
187
+ clearTimeout(settle)
188
+ settle = 0
189
+ stopFrame()
190
+ releaseListeners()
191
+ }
192
+
193
+ function cancelPending() {
194
+ if (pending) clearTimeout(pending.timer)
195
+ pending = undefined
196
+ releaseListeners()
197
+ }
198
+
199
+ function endDrag() {
200
+ teardown()
201
+ setLifted(null)
202
+ setGhost(null)
203
+ from = undefined
204
+ }
205
+
206
+ function start(event: PointerEvent, item: T, card: HTMLElement) {
207
+ if (lifted() || pending) return
208
+ if (event.pointerType === 'mouse' && event.button !== 0) return
209
+ if ((event.target as Element | null)?.closest(INTERACTIVE)) return
210
+
211
+ pointer.x = event.clientX
212
+ pointer.y = event.clientY
213
+ const mouse = event.pointerType === 'mouse'
214
+
215
+ pending = {
216
+ item,
217
+ card,
218
+ startX: event.clientX,
219
+ startY: event.clientY,
220
+ mouse,
221
+ // A mouse lifts on drift; a finger has to hold still, because a finger
222
+ // that moves is scrolling.
223
+ timer: mouse ? 0 : window.setTimeout(() => lift(), LIFT_DELAY),
224
+ }
225
+
226
+ watch()
227
+ }
228
+
229
+ function lift() {
230
+ const armed = pending
231
+ if (!armed) return
232
+ clearTimeout(armed.timer)
233
+ pending = undefined
234
+
235
+ const slot = options.slotOf(armed.item.id)
236
+ if (!slot) return releaseListeners()
237
+
238
+ const rect = armed.card.getBoundingClientRect()
239
+ from = slot
240
+ grab.x = pointer.x - rect.left
241
+ grab.y = pointer.y - rect.top
242
+ setLifted({
243
+ item: armed.item,
244
+ itemId: armed.item.id,
245
+ width: rect.width,
246
+ height: rect.height,
247
+ })
248
+ setGhost({ x: rect.left, y: rect.top, settling: false })
249
+
250
+ if (!holding) {
251
+ holding = true
252
+ window.addEventListener('touchmove', preventScroll, { passive: false })
253
+ }
254
+ navigator.vibrate?.(10)
255
+ frame = requestAnimationFrame(tick)
256
+ }
257
+
258
+ function tick() {
259
+ const active = lifted()
260
+ if (!active) return
261
+ setGhost((current) =>
262
+ current
263
+ ? { ...current, x: pointer.x - grab.x, y: pointer.y - grab.y }
264
+ : current,
265
+ )
266
+ autoscroll(active.itemId)
267
+ updateSlot(active.itemId)
268
+ frame = requestAnimationFrame(tick)
269
+ }
270
+
271
+ /** Where the pointer is over, expressed as a slot for the item. */
272
+ function slotUnderPointer(): KanbanSlot | undefined {
273
+ for (const columnId of options.columnIds()) {
274
+ const element = options.columnScroller(columnId)
275
+ if (!element) continue
276
+ const rect = element.getBoundingClientRect()
277
+ // The gaps between columns are not drop targets: leaving the item where
278
+ // it is beats guessing which neighbour the pointer meant.
279
+ if (pointer.x < rect.left || pointer.x > rect.right) continue
280
+
281
+ let index = 0
282
+ for (const card of element.querySelectorAll(
283
+ '[data-kanban-card]:not([data-dragging])',
284
+ )) {
285
+ const bounds = card.getBoundingClientRect()
286
+ if (pointer.y > bounds.top + bounds.height / 2) index += 1
287
+ }
288
+ return { column: columnId, index }
289
+ }
290
+ return undefined
291
+ }
292
+
293
+ function updateSlot(itemId: string) {
294
+ const slot = slotUnderPointer()
295
+ if (!slot) return
296
+ const current = options.slotOf(itemId)
297
+ if (
298
+ current &&
299
+ current.column === slot.column &&
300
+ current.index === slot.index
301
+ )
302
+ return
303
+ options.onPreview(itemId, slot)
304
+ }
305
+
306
+ function autoscroll(itemId: string) {
307
+ const step = (overshoot: number) =>
308
+ Math.ceil((Math.min(overshoot, EDGE) / EDGE) * EDGE_SPEED)
309
+
310
+ const board = options.container()
311
+ if (board) {
312
+ const rect = board.getBoundingClientRect()
313
+ if (pointer.x < rect.left + EDGE)
314
+ board.scrollLeft -= step(rect.left + EDGE - pointer.x)
315
+ else if (pointer.x > rect.right - EDGE)
316
+ board.scrollLeft += step(pointer.x - (rect.right - EDGE))
317
+ }
318
+
319
+ const column = options.slotOf(itemId)?.column
320
+ const scroller = column ? options.columnScroller(column) : undefined
321
+ if (scroller) {
322
+ const rect = scroller.getBoundingClientRect()
323
+ if (pointer.y < rect.top + EDGE)
324
+ scroller.scrollTop -= step(rect.top + EDGE - pointer.y)
325
+ else if (pointer.y > rect.bottom - EDGE)
326
+ scroller.scrollTop += step(pointer.y - (rect.bottom - EDGE))
327
+ }
328
+ }
329
+
330
+ function commit() {
331
+ const active = lifted()
332
+ const to = active ? options.slotOf(active.itemId) : undefined
333
+ const started = from
334
+ if (active && to && started) {
335
+ if (to.column !== started.column || to.index !== started.index)
336
+ options.onDrop(active.item, started, to)
337
+ else options.onCancel()
338
+ } else {
339
+ options.onCancel()
340
+ }
341
+ endDrag()
342
+ }
343
+
344
+ function onPointerMove(event: PointerEvent) {
345
+ pointer.x = event.clientX
346
+ pointer.y = event.clientY
347
+ if (!pending) return
348
+ const drift = Math.hypot(
349
+ event.clientX - pending.startX,
350
+ event.clientY - pending.startY,
351
+ )
352
+ if (pending.mouse) {
353
+ if (drift > MOUSE_THRESHOLD) lift()
354
+ } else if (drift > DRIFT_TOLERANCE) {
355
+ cancelPending()
356
+ }
357
+ }
358
+
359
+ function onPointerUp() {
360
+ if (pending) return cancelPending()
361
+ const active = lifted()
362
+ if (!active || ghost()?.settling) return
363
+
364
+ // Land the ghost on the slot, then reveal the real card. The slot has held
365
+ // the item's place since it lifted, so the reveal is a swap with no entry
366
+ // animation and nothing else in the column moves.
367
+ const slot = options
368
+ .container()
369
+ ?.querySelector<HTMLElement>('[data-kanban-card][data-dragging]')
370
+ const landing = slot?.getBoundingClientRect()
371
+
372
+ if (!landing) return commit()
373
+
374
+ if (!motionAllowed()) {
375
+ setGhost({ x: landing.left, y: landing.top, settling: true })
376
+ return commit()
377
+ }
378
+
379
+ setGhost({ x: landing.left, y: landing.top, settling: true })
380
+ settle = window.setTimeout(commit, SETTLE_MS)
381
+ }
382
+
383
+ function onPointerCancel() {
384
+ // A cancelled pointer is the browser taking the gesture back (a scroll
385
+ // won, the window lost focus): put the card down.
386
+ if (pending) return cancelPending()
387
+ if (!lifted()) return
388
+ options.onCancel()
389
+ endDrag()
390
+ }
391
+
392
+ function cancel() {
393
+ if (pending) return cancelPending()
394
+ if (!lifted() || ghost()?.settling) return
395
+ options.onCancel()
396
+ endDrag()
397
+ }
398
+
399
+ onCleanup(teardown)
400
+
401
+ return {
402
+ state,
403
+ start,
404
+ isDragging: (itemId: string) => lifted()?.itemId === itemId,
405
+ cancel,
406
+ }
407
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The block, the placement helper its controlled consumers need, and the types
3
+ * — not the drag engine.
4
+ *
5
+ * `drag.ts` is the implementation of the gesture: tuning constants and a
6
+ * pointer-state machine. They were reaching consumers through `export *`, which
7
+ * made `EDGE_SPEED` public API and a tuning change a breaking one. The board's
8
+ * tests import that module directly, which is where an internal belongs.
9
+ */
10
+ export {
11
+ KanbanBoard,
12
+ type KanbanBoardProps,
13
+ type KanbanColumn,
14
+ type KanbanMove,
15
+ type KanbanTone,
16
+ placeKanbanItem,
17
+ } from './KanbanBoard'
@@ -0,0 +1,39 @@
1
+ import { render } from '@solidjs/testing-library'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { MobileNav, MobileNavItem } from './MobileNav'
4
+
5
+ describe('MobileNav (browser, real CSS)', () => {
6
+ it('lays the bar out as a grid of equal-width tabs', () => {
7
+ const { container } = render(() => (
8
+ <MobileNav aria-label="Primary">
9
+ <MobileNavItem label="Home" active />
10
+ <MobileNavItem label="Search" />
11
+ </MobileNav>
12
+ ))
13
+ const nav = container.querySelector(
14
+ '[data-slot="mobile-nav"]',
15
+ ) as HTMLElement
16
+ const style = getComputedStyle(nav)
17
+ expect(style.display).toBe('grid')
18
+ expect(style.gridAutoFlow).toBe('column')
19
+ const item = container.querySelector(
20
+ '[data-slot="mobile-nav-item"]',
21
+ ) as HTMLElement
22
+ expect(item.getBoundingClientRect().height).toBeGreaterThanOrEqual(56)
23
+ })
24
+
25
+ it('paints the active tab against the inactive one', () => {
26
+ const { container } = render(() => (
27
+ <MobileNav aria-label="Primary">
28
+ <MobileNavItem label="Home" active />
29
+ <MobileNavItem label="Search" />
30
+ </MobileNav>
31
+ ))
32
+ const [active, inactive] = Array.from(
33
+ container.querySelectorAll('[data-slot="mobile-nav-item"]'),
34
+ )
35
+ expect(getComputedStyle(active as HTMLElement).color).not.toBe(
36
+ getComputedStyle(inactive as HTMLElement).color,
37
+ )
38
+ })
39
+ })
@@ -0,0 +1,108 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { MobileNav, MobileNavItem } from './MobileNav'
5
+
6
+ describe('MobileNav', () => {
7
+ it('renders a nav landmark with an accessible name', () => {
8
+ render(() => (
9
+ <MobileNav aria-label="Primary">
10
+ <MobileNavItem label="Home" />
11
+ </MobileNav>
12
+ ))
13
+
14
+ expect(screen.getByRole('navigation', { name: 'Primary' })).toBeTruthy()
15
+ })
16
+
17
+ it('marks the active item and leaves the rest inactive', () => {
18
+ render(() => (
19
+ <MobileNav aria-label="Primary">
20
+ <MobileNavItem label="Home" active />
21
+ <MobileNavItem label="Search" />
22
+ </MobileNav>
23
+ ))
24
+
25
+ expect(
26
+ screen.getByRole('button', { name: 'Home' }).getAttribute('data-state'),
27
+ ).toBe('active')
28
+ expect(
29
+ screen.getByRole('button', { name: 'Search' }).getAttribute('data-state'),
30
+ ).toBe('inactive')
31
+ })
32
+
33
+ it('renders an anchor when it has a destination', () => {
34
+ render(() => (
35
+ <MobileNav aria-label="Primary">
36
+ <MobileNavItem label="Search" route="/search" />
37
+ <MobileNavItem label="Docs" href="https://example.com" />
38
+ </MobileNav>
39
+ ))
40
+
41
+ expect(
42
+ screen.getByRole('link', { name: 'Search' }).getAttribute('href'),
43
+ ).toBe('/search')
44
+ expect(
45
+ screen.getByRole('link', { name: 'Docs' }).getAttribute('href'),
46
+ ).toBe('https://example.com')
47
+ })
48
+
49
+ it('prefers route over href', () => {
50
+ render(() => (
51
+ <MobileNav aria-label="Primary">
52
+ <MobileNavItem label="Search" route="/in-app" href="/external" />
53
+ </MobileNav>
54
+ ))
55
+
56
+ expect(
57
+ screen.getByRole('link', { name: 'Search' }).getAttribute('href'),
58
+ ).toBe('/in-app')
59
+ })
60
+
61
+ it('fires onClick for a button item', async () => {
62
+ const onClick = vi.fn()
63
+ render(() => (
64
+ <MobileNav aria-label="Primary">
65
+ <MobileNavItem label="More" onClick={onClick} />
66
+ </MobileNav>
67
+ ))
68
+
69
+ await userEvent.click(screen.getByRole('button', { name: 'More' }))
70
+ expect(onClick).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('renders the label and the icon glyph', () => {
74
+ render(() => (
75
+ <MobileNav aria-label="Primary">
76
+ <MobileNavItem label="Home" icon="lucide-house" />
77
+ </MobileNav>
78
+ ))
79
+
80
+ expect(screen.getByText('Home')).toBeTruthy()
81
+ expect(document.querySelector('.lucide-house')).toBeTruthy()
82
+ })
83
+
84
+ it('renders custom content in place of the icon', () => {
85
+ render(() => (
86
+ <MobileNav aria-label="Primary">
87
+ <MobileNavItem label="You">
88
+ <span data-testid="avatar">RK</span>
89
+ </MobileNavItem>
90
+ </MobileNav>
91
+ ))
92
+
93
+ expect(screen.getByTestId('avatar')).toBeTruthy()
94
+ expect(screen.getByRole('button', { name: 'You' })).toBeTruthy()
95
+ })
96
+
97
+ it('exposes aria-current when set', () => {
98
+ render(() => (
99
+ <MobileNav aria-label="Primary">
100
+ <MobileNavItem label="Home" active aria-current="page" />
101
+ </MobileNav>
102
+ ))
103
+
104
+ expect(
105
+ screen.getByRole('button', { name: 'Home' }).getAttribute('aria-current'),
106
+ ).toBe('page')
107
+ })
108
+ })
@@ -0,0 +1,120 @@
1
+ import { Icon, type IconProp } from '@talelk/newui-atoms'
2
+ import { cn } from '@talelk/newui-atoms/internal'
3
+ import { type JSX, Show, splitProps } from 'solid-js'
4
+ import { Dynamic } from 'solid-js/web'
5
+
6
+ export interface MobileNavProps {
7
+ /** Accessible name for the `<nav>` landmark. */
8
+ 'aria-label'?: string
9
+ /** Merged into the root `<nav>`. */
10
+ class?: string
11
+ /** One or more `MobileNavItem`s, each becoming an equal-width tab. */
12
+ children?: JSX.Element
13
+ }
14
+
15
+ /**
16
+ * A bottom navigation bar. A bare grid frame: each `MobileNavItem` is one
17
+ * equal-width column (`auto-cols-fr` + `grid-flow-col`), so the bar adapts to
18
+ * any item count without a hardcoded column class.
19
+ *
20
+ * In an installed PWA the bar runs to the bottom edge and its padding clears
21
+ * the home indicator — the safe-area inset, or `1rem` where the inset is
22
+ * smaller. Presentational: it owns no selection state.
23
+ */
24
+ export function MobileNav(props: MobileNavProps): JSX.Element {
25
+ const [local] = splitProps(props, ['aria-label', 'class', 'children'])
26
+
27
+ return (
28
+ <nav
29
+ aria-label={local['aria-label']}
30
+ data-slot="mobile-nav"
31
+ class={cn(
32
+ 'grid shrink-0 auto-cols-fr grid-flow-col border-t border-outline-gray-2 bg-surface-elevation-2',
33
+ '[@media(display-mode:standalone)]:pb-[max(1rem,env(safe-area-inset-bottom))]',
34
+ local.class,
35
+ )}
36
+ >
37
+ {local.children}
38
+ </nav>
39
+ )
40
+ }
41
+
42
+ export interface MobileNavItemProps {
43
+ /** Label under the glyph; also the accessible name. */
44
+ label: string
45
+ /** Lucide icon name or component (P11). Replaced by `children`. */
46
+ icon?: IconProp
47
+ /** In-app destination. Renders an anchor; wins over `href`. */
48
+ route?: string
49
+ /** External URL. Renders an anchor when `route` is absent. */
50
+ href?: string
51
+ /** Highlight this tab. A state prop, independent of the current route. */
52
+ active?: boolean
53
+ /** Marks the item as the current page for assistive tech. */
54
+ 'aria-current'?:
55
+ | 'page'
56
+ | 'step'
57
+ | 'location'
58
+ | 'date'
59
+ | 'time'
60
+ | 'true'
61
+ | 'false'
62
+ /** Merged into the item. */
63
+ class?: string
64
+ /** Custom content in place of the icon — an avatar, a badge. */
65
+ children?: JSX.Element
66
+ /** Click handler. Set it on button items, and on links to navigate in-app. */
67
+ onClick?: (event: MouseEvent) => void
68
+ }
69
+
70
+ /**
71
+ * One tab of a `MobileNav`: a glyph over a label, with an active highlight.
72
+ * Renders a real anchor when it has a `route`/`href` and a plain button
73
+ * otherwise. `active` drives the highlight through `data-state`; it is a state
74
+ * prop, not a theme, and may stay lit across a whole section.
75
+ */
76
+ export function MobileNavItem(props: MobileNavItemProps): JSX.Element {
77
+ const [local, rest] = splitProps(props, [
78
+ 'label',
79
+ 'icon',
80
+ 'route',
81
+ 'href',
82
+ 'active',
83
+ 'class',
84
+ 'children',
85
+ 'onClick',
86
+ ])
87
+
88
+ const destination = () => local.route ?? local.href
89
+ const isLink = () => Boolean(destination())
90
+ const isActive = () => Boolean(local.active)
91
+ const rootProps = () =>
92
+ isLink() ? { href: destination() } : { type: 'button' as const }
93
+
94
+ return (
95
+ <Dynamic
96
+ component={isLink() ? 'a' : 'button'}
97
+ {...rootProps()}
98
+ {...rest}
99
+ data-slot="mobile-nav-item"
100
+ data-state={isActive() ? 'active' : 'inactive'}
101
+ onClick={local.onClick}
102
+ class={cn(
103
+ 'flex min-h-14 flex-col items-center justify-center gap-1 py-2 text-ink-gray-6 transition',
104
+ 'active:scale-95 data-[state=active]:bg-surface-blue-1 data-[state=active]:text-ink-blue-8',
105
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3',
106
+ local.class,
107
+ )}
108
+ >
109
+ <Show
110
+ when={local.children}
111
+ fallback={<Icon icon={local.icon} class="size-6" />}
112
+ >
113
+ <span aria-hidden="true" class="contents">
114
+ {local.children}
115
+ </span>
116
+ </Show>
117
+ <span class="text-xs-medium leading-tighter">{local.label}</span>
118
+ </Dynamic>
119
+ )
120
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ MobileNav,
3
+ MobileNavItem,
4
+ type MobileNavItemProps,
5
+ type MobileNavProps,
6
+ } from './MobileNav'