@dorsk/tsumikit 0.21.0 → 0.23.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.
package/README.md CHANGED
@@ -80,13 +80,38 @@ any custom SVG).
80
80
  Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
81
81
  Dropzone, CodeBlock, Callout, EmptyState, Toaster, ThemePicker, FontScalePicker.
82
82
 
83
- **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets).
83
+ **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
84
+ `layout="fixed"` makes column widths authoritative, `Column.truncate` /
85
+ `nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
86
+ `hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
87
+ paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
88
+ for a hover/focus-revealed trailing cell (always visible on touch),
89
+ `stickyOffset` for the sticky header's `top`, `size="sm"`, `loading`,
90
+ `onloadmore` footer button, `empty` as string or snippet; `data-part`
91
+ hooks on head/row/cell).
84
92
 
85
93
  **Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
86
- sidebar on desktop, overlay drawer on mobile, optionally resizable), NavItem
94
+ sidebar on desktop, overlay drawer on mobile, optionally resizable;
95
+ `layout="sidebar-full"` runs the sidebar the full height with the header over
96
+ the content column only, `stickySidebar` pins it to the viewport, and
97
+ `sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
98
+ children are `min-width: 0` so a wide title/actions row can't widen the grid on
99
+ mobile), NavItem
87
100
  (collapses to an icon rail when the sidebar is narrow), Container, Stack
88
- (vertical), Cluster (wrapping row), AutoGrid (intrinsically responsive columns
89
- no media/container query needed).
101
+ (vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
102
+ query container and stacks children full-width below 18/30/40/48rem — phone
103
+ action rows without a viewport query), AutoGrid (intrinsically responsive
104
+ columns — no media/container query needed).
105
+
106
+ ### Container
107
+
108
+ `size` caps the column (`--content-max` by default; `size="none"` fills the parent),
109
+ `gutter` overrides the `--sp-4` inline padding (safe-area insets still win),
110
+ `align="start"` drops the centering. `fullWidth` is a *viewport* breakout for
111
+ edge-to-edge sections — inside AppShell's main column use `size="none"` instead;
112
+ `inset="<left> <right>"` keeps a `fullWidth` container clear of docked panels.
113
+ Children can bleed to the column edge with
114
+ `margin-inline: calc(-1 * var(--container-gutter))`.
90
115
 
91
116
  ### Stacked distribution + legend
92
117
 
@@ -184,6 +209,37 @@ Button and Popover share the same semantic tones. For a confirmed positive
184
209
  action, `tone="success"` gives neutral controls a success tint; combine it with
185
210
  `variant="primary"` for a filled success action without consumer CSS.
186
211
 
212
+ ### Square boxes & touch targets
213
+
214
+ Icon-ish controls share one square scale, `box="xs" | "sm" | "md" | "lg"` =
215
+ `--box-xs/sm/md/lg` (1.5rem / `--control-height-compact` / 2.25rem /
216
+ `--control-height-default`): `IconButton`, `Button`, `SelectButton`, `Popover`
217
+ (trigger), `CopyButton` (hides its label) and `FileButton` (implies `iconOnly`).
218
+ A boxed control is always square, padding-less and `flex: none`, so a header
219
+ row of IconButton + SelectButton + Popover lines up with no per-app overrides.
220
+ `md` is the classic 2.25rem icon button and the default; `chip` is an outlined
221
+ `lg`. `Button square` instead takes its side from the height contract in force
222
+ (`size` tier, or `--control-height` with `control`), for a square text glyph in
223
+ a toolbar. `IconButton glyphSize` sets the exact glyph size for SVG *and* text
224
+ glyphs (`size` stays the SVG px, emoji ×1.35).
225
+
226
+ On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
227
+ trigger) carries an invisible `::after` slab that extends its hit area to
228
+ `--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
229
+ untouched. `hitArea="compact"` opts a dense-table row out. `Button
230
+ collapseLabel="mobile" | "container"` hides `<span data-label>` children below
231
+ 40rem viewport / 30rem container, drops to icon padding, and names the button
232
+ from the hidden text unless `aria-label` is set:
233
+
234
+ ```svelte
235
+ <Button collapseLabel="container"><Icon name="search" /><span data-label>Search</span></Button>
236
+ <Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
237
+ ```
238
+
239
+ `--control-height-*` and `--box-*` are **required tokens**: a consumer that
240
+ forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
241
+ or those controls render with unresolved sizes.
242
+
187
243
  ## Built on the platform
188
244
 
189
245
  Interactive components lean on modern web features rather than reimplementing
@@ -21,15 +21,29 @@
21
21
  href?: string;
22
22
  target?: string;
23
23
  rel?: string;
24
- // Square 2.25rem icon-only tap target (IconButton). `iconInline` is the
25
- // borderless, compact variant (chip-remove ✕, inline edit ✎); pair with
26
- // `hoverDanger` to tint it red on hover (delete affordances).
24
+ // Square icon-only tap target (IconButton), `--box-md` (2.25rem) unless
25
+ // `box` says otherwise. `iconInline` is the borderless, compact variant
26
+ // (chip-remove ✕, inline edit ✎); pair with `hoverDanger` to tint it red on
27
+ // hover (delete affordances).
27
28
  icon?: boolean;
28
- // Larger 2.5rem outlined square icon-chip (header/toolbar action). Pairs
29
- // with `tone` for tinted severity chips (back/archive/interrupt/more).
29
+ // Shared square box scale (`--box-xs/sm/md/lg`): always square, no padding,
30
+ // never flexes. Implies an icon-only control.
31
+ box?: 'xs' | 'sm' | 'md' | 'lg';
32
+ // Square text/icon control whose side equals the current height contract:
33
+ // `size` tier, or `--control-height` with `control`.
34
+ square?: boolean;
35
+ // Outlined `--box-lg` icon-chip (header/toolbar action). Pairs with `tone`
36
+ // for tinted severity chips (back/archive/interrupt/more).
30
37
  chip?: boolean;
31
38
  iconInline?: boolean;
32
39
  hoverDanger?: boolean;
40
+ // Hide `[data-label]` children below 40rem viewport (`mobile`) or 30rem
41
+ // container (`container`) so the button becomes icon-only; the hidden text
42
+ // becomes the aria-label unless one is set.
43
+ collapseLabel?: 'never' | 'mobile' | 'container';
44
+ // Icon-only/square buttons grow a 44px hit slab on coarse pointers without
45
+ // moving layout; `compact` opts out in dense tables.
46
+ hitArea?: 'auto' | 'compact';
33
47
  // Async/busy state: shows a spinner, blocks clicks, sets aria-busy. Stays
34
48
  // disabled-equivalent while true (so a double-submit can't fire).
35
49
  loading?: boolean;
@@ -47,9 +61,13 @@
47
61
  as = 'button',
48
62
  href,
49
63
  icon = false,
64
+ box,
65
+ square = false,
50
66
  chip = false,
51
67
  iconInline = false,
52
68
  hoverDanger = false,
69
+ collapseLabel = 'never',
70
+ hitArea = 'auto',
53
71
  loading = false,
54
72
  type = 'button',
55
73
  disabled = false,
@@ -69,10 +87,22 @@
69
87
  ? { href, 'aria-disabled': inactive || undefined }
70
88
  : { type, disabled: inactive }
71
89
  );
90
+
91
+ let el = $state<HTMLElement | null>(null);
92
+ const explicitName = $derived(rest['aria-label'] ?? rest['aria-labelledby']);
93
+ $effect(() => {
94
+ if (!el || collapseLabel === 'never' || explicitName) return;
95
+ const text = Array.from(el.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
96
+ .filter(Boolean)
97
+ .join(' ');
98
+ if (text) el.setAttribute('aria-label', text);
99
+ else el.removeAttribute('aria-label');
100
+ });
72
101
  </script>
73
102
 
74
103
  <svelte:element
75
104
  this={tag}
105
+ bind:this={el}
76
106
  data-tsu="Button"
77
107
  {...rest}
78
108
  {...elementAttrs}
@@ -88,7 +118,13 @@
88
118
  class:btn-control={control}
89
119
  class:btn-block={block}
90
120
  class:btn-icon={icon}
121
+ class:btn-box={box !== undefined}
122
+ class:btn-square={square}
91
123
  class:btn-chip={chip}
124
+ class:btn-collapse-mobile={collapseLabel === 'mobile'}
125
+ class:btn-collapse-container={collapseLabel === 'container'}
126
+ class:hit-compact={hitArea === 'compact'}
127
+ style:--btn-box={box ? `var(--box-${box})` : undefined}
92
128
  class:btn-tone-accent={tone === 'accent'}
93
129
  class:btn-tone-success={tone === 'success'}
94
130
  class:btn-tone-info={tone === 'info'}
@@ -226,11 +262,12 @@
226
262
  /* Icon-chip: larger square outlined tap target for header/toolbar actions.
227
263
  Combines with a tone for tinted severity chips. */
228
264
  .btn-chip {
229
- min-height: 2.5rem;
230
- min-width: 2.5rem;
231
- height: 2.5rem;
232
- width: 2.5rem;
265
+ min-height: var(--box-lg);
266
+ min-width: var(--box-lg);
267
+ height: var(--box-lg);
268
+ width: var(--box-lg);
233
269
  padding: 0;
270
+ flex: none;
234
271
  border-radius: var(--r-md);
235
272
  }
236
273
 
@@ -317,12 +354,41 @@
317
354
  filter: brightness(1.08);
318
355
  }
319
356
 
320
- /* Icon-only buttons (IconButton). Square box = consistent 2.25rem tap target. */
357
+ /* Icon-only buttons (IconButton). Square box = consistent --box-md tap target. */
321
358
  .btn-icon {
322
- min-height: 2.25rem;
323
- min-width: 2.25rem;
359
+ min-height: var(--box-md);
360
+ min-width: var(--box-md);
324
361
  padding: var(--sp-2);
325
362
  }
363
+ /* `square`: side = the height contract in force (size tier or control). */
364
+ .btn-square {
365
+ width: var(--control-height-default);
366
+ min-width: var(--control-height-default);
367
+ padding: 0;
368
+ flex: none;
369
+ }
370
+ .btn-square.btn-sm {
371
+ width: var(--control-height-compact);
372
+ min-width: var(--control-height-compact);
373
+ }
374
+ .btn-square.btn-lg {
375
+ width: var(--control-height-large);
376
+ min-width: var(--control-height-large);
377
+ height: var(--control-height-large);
378
+ }
379
+ .btn-square.btn-control {
380
+ width: var(--control-height);
381
+ min-width: var(--control-height);
382
+ }
383
+ /* `box`: explicit square scale, wins over icon/chip/square/size sizing. */
384
+ .btn-box {
385
+ width: var(--btn-box);
386
+ min-width: var(--btn-box);
387
+ height: var(--btn-box);
388
+ min-height: var(--btn-box);
389
+ padding: 0;
390
+ flex: none;
391
+ }
326
392
  .btn-icon-inline {
327
393
  min-height: 0;
328
394
  min-width: 0;
@@ -340,6 +406,49 @@
340
406
  color: var(--danger);
341
407
  }
342
408
 
409
+ /* Label collapse: `[data-label]` children disappear below the threshold and
410
+ the button falls back to icon-only padding. */
411
+ @media (max-width: 40rem) {
412
+ .btn-collapse-mobile :global([data-label]) {
413
+ display: none;
414
+ }
415
+ .btn-collapse-mobile {
416
+ padding: var(--sp-2);
417
+ gap: 0;
418
+ }
419
+ }
420
+ @container (max-width: 30rem) {
421
+ .btn-collapse-container :global([data-label]) {
422
+ display: none;
423
+ }
424
+ .btn-collapse-container {
425
+ padding: var(--sp-2);
426
+ gap: 0;
427
+ }
428
+ }
429
+
430
+ /* Coarse pointers: icon-only and square buttons carry an invisible slab that
431
+ extends the hit area to --touch-target without changing layout. Buttons
432
+ already at or above the target keep their own box (inset never positive). */
433
+ @media (pointer: coarse) {
434
+ .btn-icon:not(.hit-compact),
435
+ .btn-icon-inline:not(.hit-compact),
436
+ .btn-box:not(.hit-compact),
437
+ .btn-square:not(.hit-compact),
438
+ .btn-chip:not(.hit-compact) {
439
+ position: relative;
440
+ }
441
+ .btn-icon:not(.hit-compact)::after,
442
+ .btn-icon-inline:not(.hit-compact)::after,
443
+ .btn-box:not(.hit-compact)::after,
444
+ .btn-square:not(.hit-compact)::after,
445
+ .btn-chip:not(.hit-compact)::after {
446
+ content: '';
447
+ position: absolute;
448
+ inset: min(0px, calc((100% - var(--touch-target)) / 2));
449
+ }
450
+ }
451
+
343
452
  /* Two-state (toggle) buttons — e.g. an IconButton with `pressed`. Reacts to
344
453
  the native aria-pressed that flows through, so no extra class. Tint defaults
345
454
  to the accent; override per-instance with `style="--btn-on: var(--warn)"`. */
@@ -12,9 +12,13 @@ type ButtonProps = HTMLButtonAttributes & {
12
12
  target?: string;
13
13
  rel?: string;
14
14
  icon?: boolean;
15
+ box?: 'xs' | 'sm' | 'md' | 'lg';
16
+ square?: boolean;
15
17
  chip?: boolean;
16
18
  iconInline?: boolean;
17
19
  hoverDanger?: boolean;
20
+ collapseLabel?: 'never' | 'mobile' | 'container';
21
+ hitArea?: 'auto' | 'compact';
18
22
  loading?: boolean;
19
23
  class?: string;
20
24
  children?: Snippet;
@@ -24,7 +24,10 @@
24
24
  resizableSidebar = false,
25
25
  minSidebar = 64,
26
26
  maxSidebar = 360,
27
- sidebarWidthKey
27
+ sidebarWidthKey,
28
+ layout = 'header-top',
29
+ stickySidebar = false,
30
+ sidebarPadding = 'md'
28
31
  }: {
29
32
  header?: Snippet;
30
33
  sidebar?: Snippet;
@@ -41,6 +44,14 @@
41
44
  maxSidebar?: number;
42
45
  /** localStorage key to persist the resized width. */
43
46
  sidebarWidthKey?: string;
47
+ /** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
48
+ * and the header only covers the content column (brand sits top-left). */
49
+ layout?: 'header-top' | 'sidebar-full';
50
+ /** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
51
+ * footer stays visible on long pages. */
52
+ stickySidebar?: boolean;
53
+ /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
54
+ sidebarPadding?: 'none' | 'sm' | 'md';
44
55
  } = $props();
45
56
 
46
57
  let open = $state(false);
@@ -115,7 +126,14 @@
115
126
 
116
127
  <svelte:window onkeydown={(e) => e.key === 'Escape' && (open = false)} />
117
128
 
118
- <div class="shell" class:dragging style="--shell-sidebar-w: {widthCss}" data-tsu="AppShell">
129
+ <div
130
+ class="shell"
131
+ class:dragging
132
+ class:sidebar-full={layout === 'sidebar-full'}
133
+ class:sticky-sidebar={stickySidebar}
134
+ style="--shell-sidebar-w: {widthCss}"
135
+ data-tsu="AppShell"
136
+ >
119
137
  <header class="shell-header">
120
138
  {#if sidebar}
121
139
  <!-- Wrapper owned here so the responsive hide is a scoped rule on our own
@@ -143,6 +161,7 @@
143
161
  <aside
144
162
  class="shell-sidebar"
145
163
  class:open
164
+ data-padding={sidebarPadding}
146
165
  aria-label={navLabel}
147
166
  inert={isMobile && !open ? true : undefined}
148
167
  >
@@ -190,6 +209,7 @@
190
209
  display: flex;
191
210
  align-items: center;
192
211
  gap: var(--sp-3);
212
+ min-width: 0;
193
213
  height: var(--header-h);
194
214
  padding-inline: max(var(--sp-4), var(--safe-left)) max(var(--sp-4), var(--safe-right));
195
215
  padding-top: var(--safe-top);
@@ -197,6 +217,9 @@
197
217
  backdrop-filter: blur(8px);
198
218
  border-bottom: 1px solid var(--border);
199
219
  }
220
+ .shell-header > :global(*) {
221
+ min-width: 0;
222
+ }
200
223
  .shell-menu-btn {
201
224
  display: inline-flex;
202
225
  align-items: center;
@@ -229,15 +252,22 @@
229
252
  -webkit-overflow-scrolling: touch;
230
253
  background: var(--bg-elevated);
231
254
  border-right: 1px solid var(--border);
232
- padding: var(--sp-3);
233
- padding-top: max(var(--sp-3), var(--safe-top));
234
- padding-bottom: max(var(--sp-3), var(--safe-bottom));
255
+ --shell-sidebar-pad: var(--sp-3);
256
+ padding: var(--shell-sidebar-pad);
257
+ padding-top: max(var(--shell-sidebar-pad), var(--safe-top));
258
+ padding-bottom: max(var(--shell-sidebar-pad), var(--safe-bottom));
235
259
  /* A query container named `sidebar` so nav items collapse to an icon rail
236
260
  based on the sidebar's own width (see NavItem). */
237
261
  container: sidebar / inline-size;
238
262
  transform: translateX(-100%);
239
263
  transition: transform 0.2s var(--ease);
240
264
  }
265
+ .shell-sidebar[data-padding='sm'] {
266
+ --shell-sidebar-pad: var(--sp-2);
267
+ }
268
+ .shell-sidebar[data-padding='none'] {
269
+ --shell-sidebar-pad: 0px;
270
+ }
241
271
  .shell-sidebar.open {
242
272
  transform: translateX(0);
243
273
  box-shadow: var(--shadow-lg);
@@ -273,6 +303,12 @@
273
303
  'sidebar main'
274
304
  'footer footer';
275
305
  }
306
+ .shell.sidebar-full {
307
+ grid-template-areas:
308
+ 'sidebar header'
309
+ 'sidebar main'
310
+ 'sidebar footer';
311
+ }
276
312
  .shell-sidebar {
277
313
  position: relative; /* anchor the absolute resize handle */
278
314
  grid-area: sidebar;
@@ -281,6 +317,12 @@
281
317
  box-shadow: none;
282
318
  border-right: 1px solid var(--border);
283
319
  }
320
+ .shell.sticky-sidebar .shell-sidebar {
321
+ position: sticky;
322
+ top: 0;
323
+ height: 100dvh;
324
+ align-self: start;
325
+ }
284
326
  .shell-scrim,
285
327
  .shell-menu-btn {
286
328
  display: none !important;
@@ -15,6 +15,14 @@ type $$ComponentProps = {
15
15
  maxSidebar?: number;
16
16
  /** localStorage key to persist the resized width. */
17
17
  sidebarWidthKey?: string;
18
+ /** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
19
+ * and the header only covers the content column (brand sits top-left). */
20
+ layout?: 'header-top' | 'sidebar-full';
21
+ /** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
22
+ * footer stays visible on long pages. */
23
+ stickySidebar?: boolean;
24
+ /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
25
+ sidebarPadding?: 'none' | 'sm' | 'md';
18
26
  };
19
27
  declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
20
28
  type AppShell = ReturnType<typeof AppShell>;
@@ -10,6 +10,9 @@
10
10
  // SegmentedControl/Select `control`), so a whole toolbar lines up from one prop
11
11
  // instead of per-child sizing. `grow` makes direct children share the row width
12
12
  // equally (flex: 1) — the toolbar equivalent of `style="flex:1"` on each child.
13
+ // `stackAt` turns the cluster into its own inline-size query container and
14
+ // stacks children full-width once it is narrower than the tier (18/30/40/48rem),
15
+ // for phone action rows; the cluster then no longer shrink-wraps its content.
13
16
  const CONTROL_TIER = {
14
17
  sm: 'var(--control-height-compact)',
15
18
  md: 'var(--control-height-default)',
@@ -24,6 +27,7 @@
24
27
  wrap = true,
25
28
  size,
26
29
  grow = false,
30
+ stackAt,
27
31
  class: klass = '',
28
32
  children,
29
33
  ...rest
@@ -35,6 +39,7 @@
35
39
  wrap?: boolean;
36
40
  size?: 'sm' | 'md' | 'lg';
37
41
  grow?: boolean;
42
+ stackAt?: 'xs' | 'sm' | 'md' | 'lg';
38
43
  class?: string;
39
44
  children?: Snippet;
40
45
  [key: string]: unknown;
@@ -46,6 +51,11 @@
46
51
  data-tsu="Cluster"
47
52
  class="cluster-c {klass}"
48
53
  class:cluster-grow={grow}
54
+ class:cluster-stack={stackAt !== undefined}
55
+ class:stack-xs={stackAt === 'xs'}
56
+ class:stack-sm={stackAt === 'sm'}
57
+ class:stack-md={stackAt === 'md'}
58
+ class:stack-lg={stackAt === 'lg'}
49
59
  style:gap
50
60
  style:align-items={align}
51
61
  style:justify-content={justify}
@@ -64,4 +74,27 @@
64
74
  flex: 1 1 0;
65
75
  min-width: 0;
66
76
  }
77
+ .cluster-stack {
78
+ container-type: inline-size;
79
+ }
80
+ @container (max-width: 18rem) {
81
+ .stack-xs > :global(*) {
82
+ flex: 1 1 100%;
83
+ }
84
+ }
85
+ @container (max-width: 30rem) {
86
+ .stack-sm > :global(*) {
87
+ flex: 1 1 100%;
88
+ }
89
+ }
90
+ @container (max-width: 40rem) {
91
+ .stack-md > :global(*) {
92
+ flex: 1 1 100%;
93
+ }
94
+ }
95
+ @container (max-width: 48rem) {
96
+ .stack-lg > :global(*) {
97
+ flex: 1 1 100%;
98
+ }
99
+ }
67
100
  </style>
@@ -7,6 +7,7 @@ type $$ComponentProps = {
7
7
  wrap?: boolean;
8
8
  size?: 'sm' | 'md' | 'lg';
9
9
  grow?: boolean;
10
+ stackAt?: 'xs' | 'sm' | 'md' | 'lg';
10
11
  class?: string;
11
12
  children?: Snippet;
12
13
  [key: string]: unknown;
@@ -1,31 +1,51 @@
1
1
  <script lang="ts">
2
- // Centered, max-width content column with token gutters that respect safe-area
3
- // insets. `size` overrides the default --content-max; `pad` toggles vertical
4
- // padding. `fullWidth` releases the max-width constraint and lets the content
5
- // bleed to the full viewport width even when nested inside a centered ancestor
6
- // (the `margin-inline: calc(50% - 50vw)` trick), for edge-to-edge sections.
7
- // Polymorphic via `as` so it can be a <main>, <section>, etc.
2
+ // Max-width content column with token gutters that respect safe-area insets.
3
+ // Self-contained: does not depend on the global `.container` utility. Exposes
4
+ // `--container-gutter` so children can bleed to the column edge with
5
+ // `margin-inline: calc(-1 * var(--container-gutter))`.
8
6
  import type { Snippet } from 'svelte';
9
7
 
10
8
  let {
11
9
  as = 'div',
12
10
  size,
11
+ gutter,
12
+ align = 'center',
13
13
  pad = false,
14
14
  fullWidth = false,
15
+ inset,
15
16
  class: klass = '',
16
17
  children,
17
18
  ...rest
18
19
  }: {
19
20
  as?: 'div' | 'main' | 'section' | 'article';
20
- /** Max width (any CSS length). Defaults to --content-max. Ignored when `fullWidth`. */
21
- size?: string;
21
+ /** Max width (any CSS length), or `'none'` to fill the parent. Defaults to --content-max. Ignored when `fullWidth`. */
22
+ size?: string | 'none';
23
+ /** Inline gutter (any CSS length). Defaults to --sp-4; safe-area insets still win when larger. */
24
+ gutter?: string;
25
+ /** `'center'` (margin-inline auto) or `'start'` (flush with the parent's start edge). */
26
+ align?: 'center' | 'start';
22
27
  pad?: boolean;
23
- /** Break out to the full viewport width, ignoring `size`/--content-max. */
28
+ /** Break out to the full *viewport* width, ignoring `size`/--content-max and any
29
+ * centered ancestor. Inside a sidebar layout prefer `size="none"` (fills the column). */
24
30
  fullWidth?: boolean;
31
+ /** Space reserved at the viewport edges when `fullWidth` (`'left right'` or one value
32
+ * for both), e.g. `"var(--dock-left-w) var(--dock-right-w)"` for docked panels. */
33
+ inset?: string;
25
34
  class?: string;
26
35
  children?: Snippet;
27
36
  [key: string]: unknown;
28
37
  } = $props();
38
+
39
+ const style = $derived.by(() => {
40
+ const vars: string[] = [];
41
+ if (!fullWidth && size && size !== 'none') vars.push(`--ct-max: ${size}`);
42
+ if (gutter) vars.push(`--container-gutter: ${gutter}`);
43
+ if (fullWidth && inset) {
44
+ const [left, right = left] = inset.trim().split(/\s+/);
45
+ vars.push(`--ct-inset-l: ${left}`, `--ct-inset-r: ${right}`);
46
+ }
47
+ return vars.length ? vars.join('; ') : undefined;
48
+ });
29
49
  </script>
30
50
 
31
51
  <svelte:element
@@ -33,25 +53,46 @@
33
53
  data-tsu="Container"
34
54
  class="container ct {klass}"
35
55
  class:pad
56
+ class:none={size === 'none'}
57
+ class:start={align === 'start'}
36
58
  class:full={fullWidth}
37
- style={!fullWidth && size ? `max-width: ${size}` : undefined}
59
+ {style}
38
60
  {...rest}
39
61
  >
40
62
  {@render children?.()}
41
63
  </svelte:element>
42
64
 
43
65
  <style>
66
+ .ct {
67
+ --container-gutter: var(--sp-4);
68
+ width: 100%;
69
+ max-width: var(--ct-max, var(--content-max));
70
+ margin-inline: auto;
71
+ padding-inline: max(var(--container-gutter), var(--safe-left))
72
+ max(var(--container-gutter), var(--safe-right));
73
+ }
74
+
75
+ .ct.none {
76
+ max-width: none;
77
+ }
78
+
79
+ .ct.start {
80
+ margin-inline: 0;
81
+ }
82
+
44
83
  .ct.pad {
45
84
  padding-top: var(--sp-6);
46
85
  padding-bottom: var(--sp-12);
47
86
  }
48
87
 
49
- /* Break out of any centered ancestor to span the full viewport width.
88
+ /* Break out of any centered ancestor to span the viewport (minus `inset`).
50
89
  `margin-inline: calc(50% - 50vw)` pulls each edge out to the viewport,
51
90
  keeping the element in normal flow (no transform/overflow side-effects). */
52
91
  .ct.full {
92
+ --ct-inset-l: 0px;
93
+ --ct-inset-r: 0px;
53
94
  max-width: none;
54
- width: 100vw;
55
- margin-inline: calc(50% - 50vw);
95
+ width: calc(100vw - var(--ct-inset-l) - var(--ct-inset-r));
96
+ margin-inline: calc(50% - 50vw + var(--ct-inset-l)) calc(50% - 50vw + var(--ct-inset-r));
56
97
  }
57
98
  </style>
@@ -1,11 +1,19 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  as?: 'div' | 'main' | 'section' | 'article';
4
- /** Max width (any CSS length). Defaults to --content-max. Ignored when `fullWidth`. */
5
- size?: string;
4
+ /** Max width (any CSS length), or `'none'` to fill the parent. Defaults to --content-max. Ignored when `fullWidth`. */
5
+ size?: string | 'none';
6
+ /** Inline gutter (any CSS length). Defaults to --sp-4; safe-area insets still win when larger. */
7
+ gutter?: string;
8
+ /** `'center'` (margin-inline auto) or `'start'` (flush with the parent's start edge). */
9
+ align?: 'center' | 'start';
6
10
  pad?: boolean;
7
- /** Break out to the full viewport width, ignoring `size`/--content-max. */
11
+ /** Break out to the full *viewport* width, ignoring `size`/--content-max and any
12
+ * centered ancestor. Inside a sidebar layout prefer `size="none"` (fills the column). */
8
13
  fullWidth?: boolean;
14
+ /** Space reserved at the viewport edges when `fullWidth` (`'left right'` or one value
15
+ * for both), e.g. `"var(--dock-left-w) var(--dock-right-w)"` for docked panels. */
16
+ inset?: string;
9
17
  class?: string;
10
18
  children?: Snippet;
11
19
  [key: string]: unknown;
@@ -13,7 +13,9 @@
13
13
  label = 'Copy',
14
14
  copiedLabel = 'Copied',
15
15
  variant = 'ghost',
16
- showLabel = true,
16
+ showLabel,
17
+ box,
18
+ hitArea = 'auto',
17
19
  resetMs = 1500,
18
20
  class: klass = ''
19
21
  }: {
@@ -22,11 +24,16 @@
22
24
  label?: string;
23
25
  copiedLabel?: string;
24
26
  variant?: 'default' | 'primary' | 'ghost' | 'danger';
27
+ /** Defaults to true, or false when `box` makes it icon-only. */
25
28
  showLabel?: boolean;
29
+ /** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
30
+ box?: 'xs' | 'sm' | 'md' | 'lg';
31
+ hitArea?: 'auto' | 'compact';
26
32
  resetMs?: number;
27
33
  class?: string;
28
34
  } = $props();
29
35
 
36
+ const labelShown = $derived(showLabel ?? box === undefined);
30
37
  let copied = $state(false);
31
38
  let status = $state('');
32
39
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -46,12 +53,14 @@
46
53
  <Button
47
54
  data-tsu="CopyButton"
48
55
  {variant}
56
+ {box}
57
+ {hitArea}
49
58
  class={klass}
50
59
  onclick={copy}
51
60
  aria-label={copied ? copiedLabel : label}
52
61
  title={label}
53
62
  >
54
63
  <Icon name={copied ? 'check' : 'copy'} />
55
- {#if showLabel}<span>{copied ? copiedLabel : label}</span>{/if}
64
+ {#if labelShown}<span>{copied ? copiedLabel : label}</span>{/if}
56
65
  </Button>
57
66
  <span class="sr-only" role="status" aria-live="polite">{status}</span>