@stacksjs/components 0.2.217 → 0.2.219

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.
@@ -0,0 +1,50 @@
1
+ <script server>
2
+ /** One pill. See `FilterChips` for why a menu chip is not a toggle chip. */
3
+ export const label = $props.label || ''
4
+ export const href = $props.href || '#'
5
+ export const active = $props.active || false
6
+ /** Opens a picker rather than toggling in place — draws a chevron. */
7
+ export const menu = $props.menu || false
8
+ export const count = $props.count || ''
9
+
10
+ export const hasCount = count !== '' && count !== 0 && count !== '0'
11
+
12
+ /*
13
+ * `shrink-0`, because the row scrolls.
14
+ *
15
+ * Without it flexbox distributes the overflow by squeezing every chip, and a
16
+ * row of filters that cannot overflow instead renders as a row of ellipses.
17
+ */
18
+ export const chipClasses = `
19
+ inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 py-2
20
+ text-sm font-medium transition-colors
21
+ ${active
22
+ ? 'border-transparent bg-fg text-surface'
23
+ : 'border-line bg-surface-raised text-fg hover:bg-surface-sunken'}
24
+ `.replace(/\s+/g, ' ').trim()
25
+ </script>
26
+
27
+ <a
28
+ href="{{ href }}"
29
+ class="{{ chipClasses }}"
30
+ data-stx-filter-chip
31
+ @if (active)
32
+ aria-current="true"
33
+ @endif
34
+ >
35
+ <span>{{ label }}</span>
36
+
37
+ @if (hasCount)
38
+ <span class="rounded-full bg-surface-sunken px-1.5 text-xs font-semibold text-fg-muted">{{ count }}</span>
39
+ @endif
40
+
41
+ @if (menu)
42
+ {{--
43
+ The chevron is the whole difference between "this filters the list" and
44
+ "this opens a picker", so it is drawn rather than implied.
45
+ --}}
46
+ <svg class="h-4 w-4 opacity-60" viewBox="0 0 20 20" fill="none" aria-hidden="true">
47
+ <path d="M6 8l4 4 4-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
48
+ </svg>
49
+ @endif
50
+ </a>
@@ -0,0 +1,41 @@
1
+ <script server>
2
+ /**
3
+ * The row of pills under a screen title.
4
+ *
5
+ * Two kinds of chip live in the same row and they are not the same control. A
6
+ * **toggle** is on or off — "Unread". A **menu** opens something and carries a
7
+ * chevron — "Repository". Rendering both as bare pills, which is the tempting
8
+ * shortcut, means a reader cannot tell which of them will change the list in
9
+ * place and which will cover the screen with a picker.
10
+ *
11
+ * `chips` is `{ label, href, active?, menu?, count? }`.
12
+ */
13
+ export const chips = $props.chips || []
14
+ export const className = $props.className || ''
15
+
16
+ /*
17
+ * Scrolls horizontally, and says so by bleeding past the gutter.
18
+ *
19
+ * Filters overflow a phone's width the moment there are four of them with real
20
+ * words in. The negative margin plus matching padding lets the row run to both
21
+ * screen edges while its first chip still lines up with the title above it —
22
+ * a row that stops at the gutter looks complete when it is not.
23
+ */
24
+ export const rowClasses = `
25
+ -mx-4 flex gap-2 overflow-x-auto px-4 pb-1
26
+ [scrollbar-width:none] [&::-webkit-scrollbar]:hidden
27
+ ${className}
28
+ `.replace(/\s+/g, ' ').trim()
29
+ </script>
30
+
31
+ <div class="{{ rowClasses }}" data-stx-filter-chips role="group" aria-label="Filters">
32
+ @foreach (chips as chip)
33
+ <FilterChip
34
+ :label="chip.label"
35
+ :href="chip.href"
36
+ :active="chip.active"
37
+ :menu="chip.menu"
38
+ :count="chip.count"
39
+ />
40
+ @endforeach
41
+ </div>
@@ -0,0 +1,98 @@
1
+ <script server>
2
+ /**
3
+ * The row every mobile list is made of.
4
+ *
5
+ * Read off what a dense list actually has to carry: a leading mark, a line of
6
+ * context above the title, the title itself over two lines, a line of detail
7
+ * under it, a timestamp at the top right, and a count at the bottom right.
8
+ * Six slots, because a list that can only do three ends up with applications
9
+ * inventing a seventh layout per screen.
10
+ *
11
+ * Everything except `title` is optional and nothing reserves space when absent,
12
+ * so the same component renders a two-line notification and a five-line issue
13
+ * without either looking like a version of the other.
14
+ */
15
+ export const title = $props.title || ''
16
+ /** Small line above the title — a repository, an owner, a path. */
17
+ export const eyebrow = $props.eyebrow || ''
18
+ /** Small line below the title — an actor, a branch, a summary. */
19
+ export const detail = $props.detail || ''
20
+ /** Top right. A relative time, usually. */
21
+ export const meta = $props.meta || ''
22
+ /** Bottom right. A comment count, a diff stat, a status. */
23
+ export const trailing = $props.trailing || ''
24
+ /** Inline SVG for the leading column. */
25
+ export const icon = $props.icon || ''
26
+ /** Draws the unread dot in the leading column, under the icon. */
27
+ export const unread = $props.unread || false
28
+ export const href = $props.href || ''
29
+ export const className = $props.className || ''
30
+
31
+ export const interactive = href !== ''
32
+ export const tag = interactive ? 'a' : 'div'
33
+
34
+ export const rowClasses = `
35
+ flex w-full items-start gap-3 px-4 py-3 text-left
36
+ ${interactive ? 'transition-colors active:bg-surface-sunken' : ''}
37
+ ${className}
38
+ `.replace(/\s+/g, ' ').trim()
39
+ </script>
40
+
41
+ <{{ tag }}
42
+ class="{{ rowClasses }}"
43
+ data-stx-list-row
44
+ @if (interactive)
45
+ href="{{ href }}"
46
+ @endif
47
+ >
48
+ @if (icon || unread)
49
+ <span class="flex w-6 shrink-0 flex-col items-center gap-1.5 pt-0.5" aria-hidden="true">
50
+ {!! icon !!}
51
+
52
+ @if (unread)
53
+ {{--
54
+ A dot, not a bold title. Weight is already carrying the title's
55
+ hierarchy, and an unread list rendered entirely in bold has no
56
+ hierarchy left to spend.
57
+ --}}
58
+ <span class="h-2 w-2 rounded-full bg-accent"></span>
59
+ @endif
60
+ </span>
61
+ @endif
62
+
63
+ <span class="min-w-0 flex-1">
64
+ @if (eyebrow)
65
+ <span class="block truncate text-sm text-fg-muted">{{ eyebrow }}</span>
66
+ @endif
67
+
68
+ {{--
69
+ Two lines, then clipped. One line loses the end of nearly every issue
70
+ title; unbounded lets a single pasted stack trace push the rest of the
71
+ list off the screen.
72
+ --}}
73
+ <span class="block text-[15px] font-semibold leading-snug text-fg-strong [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] overflow-hidden">
74
+ {{ title }}
75
+ </span>
76
+
77
+ @if (detail)
78
+ <span class="mt-0.5 block truncate text-sm text-fg-muted">{{ detail }}</span>
79
+ @endif
80
+ </span>
81
+
82
+ @if (meta || trailing)
83
+ {{--
84
+ The right column is a column: timestamp at the top, count at the bottom,
85
+ both right-aligned. Putting them on one line makes the row's height
86
+ depend on which of the two is present.
87
+ --}}
88
+ <span class="flex shrink-0 flex-col items-end gap-2 pt-0.5">
89
+ @if (meta)
90
+ <span class="text-sm text-fg-muted">{{ meta }}</span>
91
+ @endif
92
+
93
+ @if (trailing)
94
+ <span class="rounded-md bg-surface-sunken px-1.5 py-0.5 text-xs font-medium text-fg-muted">{{ trailing }}</span>
95
+ @endif
96
+ </span>
97
+ @endif
98
+ </{{ tag }}>
@@ -0,0 +1,63 @@
1
+ <script server>
2
+ /**
3
+ * The top of a screen: a row of controls, then a large title under it.
4
+ *
5
+ * Two rows rather than one, because they answer different questions. The
6
+ * control row is where back and the screen's actions live and it stays the same
7
+ * height everywhere; the title is display-sized and belongs to the content, so
8
+ * it scrolls away while the controls do not.
9
+ *
10
+ * `title` may be empty — a screen pushed onto a stack usually wants its name in
11
+ * the small centre slot beside the back button instead, which is what
12
+ * `compactTitle` is.
13
+ */
14
+ export const title = $props.title || ''
15
+ export const compactTitle = $props.compactTitle || ''
16
+ export const backHref = $props.backHref || ''
17
+ /** Markup for the right-hand side: search, add, an avatar. */
18
+ export const actions = $props.actions || ''
19
+ export const className = $props.className || ''
20
+
21
+ export const hasControls = backHref !== '' || compactTitle !== '' || actions !== ''
22
+
23
+ /*
24
+ * The safe-area inset is padding on the header itself.
25
+ *
26
+ * Not a spacer element above it: on a notched device the status bar sits over
27
+ * the top of the page, and a header that starts below a fixed-height spacer is
28
+ * right on exactly one device.
29
+ */
30
+ export const headerStyle = 'padding-top: max(env(safe-area-inset-top, 0px), 12px);'
31
+ </script>
32
+
33
+ <header class="px-4 {{ className }}" style="{{ headerStyle }}" data-stx-nav-bar>
34
+ @if (hasControls)
35
+ <div class="flex h-11 items-center gap-2">
36
+ @if (backHref)
37
+ <a
38
+ href="{{ backHref }}"
39
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-raised text-fg"
40
+ aria-label="Back"
41
+ >
42
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
43
+ <path d="M12 4l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
44
+ </svg>
45
+ </a>
46
+ @endif
47
+
48
+ @if (compactTitle)
49
+ <span class="truncate text-[17px] font-semibold text-fg-strong">{{ compactTitle }}</span>
50
+ @endif
51
+
52
+ @if (actions)
53
+ {{-- Pushed right by the spacer rather than by `justify-between`, which
54
+ would centre a lone action when there is no back button. --}}
55
+ <span class="ml-auto flex items-center gap-2">{!! actions !!}</span>
56
+ @endif
57
+ </div>
58
+ @endif
59
+
60
+ @if (title)
61
+ <h1 class="pb-3 pt-1 text-[34px] font-bold leading-tight tracking-tight text-fg-strong">{{ title }}</h1>
62
+ @endif
63
+ </header>
@@ -0,0 +1,42 @@
1
+ <script server>
2
+ /**
3
+ * A titled group of rows on a rounded card — the "My Work" pattern.
4
+ *
5
+ * The title sits *outside* the card. That is the whole convention: the card is
6
+ * the group, the heading names it, and putting the heading inside makes the
7
+ * first row look like a header and every subsequent group look like a
8
+ * continuation of the one above it.
9
+ *
10
+ * Dividers are drawn by the card rather than by the rows, via
11
+ * `divide-y`, so a row does not have to know whether it is last — which is how
12
+ * a trailing hairline ends up under the final item of every list.
13
+ */
14
+ export const title = $props.title || ''
15
+ /** Right-aligned control beside the title — "See all", a menu glyph. */
16
+ export const action = $props.action || ''
17
+ export const actionHref = $props.actionHref || ''
18
+ export const className = $props.className || ''
19
+
20
+ export const cardClasses = `
21
+ overflow-hidden rounded-2xl border border-line bg-surface-raised
22
+ divide-y divide-line
23
+ `.replace(/\s+/g, ' ').trim()
24
+ </script>
25
+
26
+ <section class="{{ className }}" data-stx-section-card>
27
+ @if (title || action)
28
+ <header class="flex items-baseline justify-between px-1 pb-2">
29
+ @if (title)
30
+ <h2 class="text-2xl font-bold tracking-tight text-fg-strong">{{ title }}</h2>
31
+ @endif
32
+
33
+ @if (action)
34
+ <a href="{{ actionHref }}" class="text-sm font-medium text-accent">{{ action }}</a>
35
+ @endif
36
+ </header>
37
+ @endif
38
+
39
+ <div class="{{ cardClasses }}">
40
+ <slot />
41
+ </div>
42
+ </section>
@@ -0,0 +1,53 @@
1
+ <script server>
2
+ /**
3
+ * The bottom tab bar — the one control a phone app is navigated by.
4
+ *
5
+ * Floating rather than flush: a rounded bar inset from the window edges with
6
+ * the content scrolling *under* it, which is what every current iOS app does
7
+ * and what the safe area at the bottom of a modern iPhone is shaped for. A bar
8
+ * pinned flush to the bottom either eats the home indicator or leaves a band of
9
+ * dead colour beneath itself.
10
+ *
11
+ * `items` is the whole bar. Each is `{ label, href, icon, active?, badge? }`,
12
+ * where `icon` is inline SVG or any markup — the bar does not own an icon set,
13
+ * because an application's icons are its own and a component that ships them
14
+ * makes every consumer look like the component.
15
+ */
16
+ export const items = $props.items || []
17
+ export const className = $props.className || ''
18
+
19
+ /**
20
+ * `env(safe-area-inset-bottom)` with a floor, not instead of one.
21
+ *
22
+ * On a device with a home indicator the inset is real and the bar must clear
23
+ * it. On one without — an SE, a simulator in a small window, a desktop browser
24
+ * looking at the mobile layout — the inset is `0px` and a bar with no floor
25
+ * sits on the very edge of the screen. `max()` is the only spelling that gets
26
+ * both right.
27
+ */
28
+ export const barStyle = 'bottom: max(env(safe-area-inset-bottom, 0px), 12px);'
29
+
30
+ export const barClasses = `
31
+ fixed inset-x-0 z-40 mx-auto flex w-max max-w-[calc(100%-24px)] items-stretch gap-1
32
+ rounded-full border border-line bg-surface-raised/90 px-2 py-1.5
33
+ shadow-lg backdrop-blur-xl
34
+ ${className}
35
+ `.replace(/\s+/g, ' ').trim()
36
+ </script>
37
+
38
+ {{--
39
+ A `nav` with a label, because this is the primary navigation of the
40
+ application and a screen reader landing on an unlabelled group of five links
41
+ has no way to know that.
42
+ --}}
43
+ <nav class="{{ barClasses }}" style="{{ barStyle }}" aria-label="Primary" data-stx-tab-bar>
44
+ @foreach (items as item)
45
+ <TabBarItem
46
+ :label="item.label"
47
+ :href="item.href"
48
+ :icon="item.icon"
49
+ :active="item.active"
50
+ :badge="item.badge"
51
+ />
52
+ @endforeach
53
+ </nav>
@@ -0,0 +1,55 @@
1
+ <script server>
2
+ /**
3
+ * One destination in the bottom bar.
4
+ *
5
+ * Icon over label, and the label always present. Icon-only bars test well with
6
+ * the people who designed them and badly with everyone else: a glyph is only
7
+ * unambiguous once you already know what the app calls that screen.
8
+ */
9
+ export const label = $props.label || ''
10
+ export const href = $props.href || '#'
11
+ export const icon = $props.icon || ''
12
+ export const active = $props.active || false
13
+ /** A count, or any short string. Zero and empty are not rendered. */
14
+ export const badge = $props.badge || ''
15
+
16
+ export const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
17
+
18
+ /*
19
+ * The active state is a filled pill behind the item rather than a colour
20
+ * change alone. Colour alone is the one distinction that disappears for the
21
+ * ~8% of men with a red-green deficiency and for anyone in bright sunlight,
22
+ * which is most of the time a phone is used outdoors.
23
+ */
24
+ export const itemClasses = `
25
+ relative flex min-w-[68px] flex-col items-center gap-0.5 rounded-full px-4 py-1.5
26
+ text-[11px] font-medium leading-tight transition-colors
27
+ ${active ? 'bg-surface-sunken text-accent' : 'text-fg-muted hover:text-fg'}
28
+ `.replace(/\s+/g, ' ').trim()
29
+ </script>
30
+
31
+ <a
32
+ href="{{ href }}"
33
+ class="{{ itemClasses }}"
34
+ data-stx-tab-bar-item
35
+ @if (active)
36
+ aria-current="page"
37
+ @endif
38
+ >
39
+ <span class="relative flex h-6 w-6 items-center justify-center" aria-hidden="true">
40
+ {!! icon !!}
41
+
42
+ @if (hasBadge)
43
+ {{--
44
+ The badge sits on the icon rather than after the label: it has to
45
+ survive the label being two words wide, and anchoring it to the glyph
46
+ keeps every tab's badge in the same place.
47
+ --}}
48
+ <span class="absolute -right-2 -top-1 min-w-[16px] rounded-full bg-danger px-1 text-[10px] font-semibold leading-4 text-white">
49
+ {{ badge }}
50
+ </span>
51
+ @endif
52
+ </span>
53
+
54
+ <span>{{ label }}</span>
55
+ </a>
package/dist/index.d.ts CHANGED
@@ -56,6 +56,8 @@ export * from './ui/form/index';
56
56
  export * from './ui/image/index';
57
57
  export * from './ui/input/index';
58
58
  export * from './ui/listbox/index';
59
+ // The phone primitives: a bottom tab bar, filter pills, dense list rows.
60
+ export * from './ui/mobile/index';
59
61
  export * from './ui/navigator/index';
60
62
  export * from './ui/notification/index';
61
63
  export * from './ui/pagination/index';