@lyeve-labs/ui-kit 0.11.2 → 0.13.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 (101) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/Autocomplete.svelte +191 -125
  4. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  5. package/dist/components/Button.svelte +26 -4
  6. package/dist/components/Card.svelte +61 -3
  7. package/dist/components/Card.svelte.d.ts +24 -2
  8. package/dist/components/Checkbox.svelte +174 -59
  9. package/dist/components/Checkbox.svelte.d.ts +20 -3
  10. package/dist/components/CheckboxGroup.svelte +162 -0
  11. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  12. package/dist/components/Collapsible.svelte +142 -0
  13. package/dist/components/Collapsible.svelte.d.ts +32 -0
  14. package/dist/components/CopyButton.svelte +126 -0
  15. package/dist/components/CopyButton.svelte.d.ts +14 -0
  16. package/dist/components/DatePicker.svelte +48 -6
  17. package/dist/components/DateTimePicker.svelte +337 -0
  18. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  19. package/dist/components/DescriptionList.svelte +78 -0
  20. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  21. package/dist/components/Drawer.svelte +15 -4
  22. package/dist/components/Field.svelte +104 -0
  23. package/dist/components/Field.svelte.d.ts +46 -0
  24. package/dist/components/FileInput.svelte +5 -2
  25. package/dist/components/FormMessage.svelte +85 -0
  26. package/dist/components/FormMessage.svelte.d.ts +11 -0
  27. package/dist/components/Input.svelte +1 -1
  28. package/dist/components/Label.svelte +7 -1
  29. package/dist/components/Label.svelte.d.ts +6 -0
  30. package/dist/components/Modal.svelte +25 -8
  31. package/dist/components/MultiSelect.svelte +199 -109
  32. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  33. package/dist/components/NumberInput.svelte +8 -4
  34. package/dist/components/PageHeader.svelte +37 -4
  35. package/dist/components/PageHeader.svelte.d.ts +15 -0
  36. package/dist/components/PageShell.svelte +85 -0
  37. package/dist/components/PageShell.svelte.d.ts +38 -0
  38. package/dist/components/Pagination.svelte +58 -17
  39. package/dist/components/Panel.svelte +101 -0
  40. package/dist/components/Panel.svelte.d.ts +39 -0
  41. package/dist/components/PasswordInput.svelte +139 -0
  42. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  43. package/dist/components/Radio.svelte +152 -32
  44. package/dist/components/Radio.svelte.d.ts +16 -1
  45. package/dist/components/RadioGroup.svelte +118 -71
  46. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  47. package/dist/components/SectionHeading.svelte +39 -0
  48. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  49. package/dist/components/SegmentedControl.svelte +194 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  51. package/dist/components/Select.svelte +471 -46
  52. package/dist/components/Select.svelte.d.ts +95 -6
  53. package/dist/components/SidebarNav.svelte +259 -0
  54. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  55. package/dist/components/Stat.svelte +53 -2
  56. package/dist/components/Stat.svelte.d.ts +31 -0
  57. package/dist/components/Textarea.svelte +1 -1
  58. package/dist/components/TimePicker.svelte +480 -0
  59. package/dist/components/TimePicker.svelte.d.ts +23 -0
  60. package/dist/components/Toaster.svelte +9 -2
  61. package/dist/components/Toggle.svelte +5 -1
  62. package/dist/components/Toggle.svelte.d.ts +2 -0
  63. package/dist/components/Toolbar.svelte +39 -0
  64. package/dist/components/Toolbar.svelte.d.ts +26 -0
  65. package/dist/components/Tooltip.svelte +48 -12
  66. package/dist/components/TreeView.svelte +339 -0
  67. package/dist/components/TreeView.svelte.d.ts +37 -0
  68. package/dist/components/dialog/Dialog.svelte +15 -58
  69. package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
  70. package/dist/components/dialog/dialog-manager.svelte.js +21 -21
  71. package/dist/index.d.ts +25 -1
  72. package/dist/index.js +20 -1
  73. package/dist/internal/calendar.d.ts +119 -0
  74. package/dist/internal/calendar.js +225 -0
  75. package/dist/internal/choice.d.ts +136 -0
  76. package/dist/internal/choice.js +179 -0
  77. package/dist/internal/field.d.ts +31 -0
  78. package/dist/internal/field.js +42 -1
  79. package/dist/internal/filter.d.ts +80 -0
  80. package/dist/internal/filter.js +80 -0
  81. package/dist/internal/layout.d.ts +119 -0
  82. package/dist/internal/layout.js +132 -0
  83. package/dist/internal/listbox.svelte.d.ts +77 -0
  84. package/dist/internal/listbox.svelte.js +438 -0
  85. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  86. package/dist/internal/nav-expansion.svelte.js +144 -0
  87. package/dist/internal/nav-tree.d.ts +68 -0
  88. package/dist/internal/nav-tree.js +102 -0
  89. package/dist/internal/overlay.d.ts +25 -0
  90. package/dist/internal/overlay.js +92 -0
  91. package/dist/internal/panel.d.ts +100 -0
  92. package/dist/internal/panel.js +109 -0
  93. package/dist/internal/rollup.d.ts +52 -0
  94. package/dist/internal/rollup.js +67 -0
  95. package/dist/internal/time.d.ts +103 -0
  96. package/dist/internal/time.js +166 -0
  97. package/dist/internal/tree.d.ts +86 -0
  98. package/dist/internal/tree.js +111 -0
  99. package/dist/styles/theme.css +66 -25
  100. package/package.json +4 -2
  101. package/src/lib/styles/theme.css +66 -25
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Key and value pairs, as a real description list.
3
+ *
4
+ * Detail pages laid these out with ad hoc grids of divs, which broke
5
+ * alignment between two pages showing the same record and, more importantly,
6
+ * conveyed nothing: a grid of divs has no relationship between a label and
7
+ * the value under it, so a screen reader reads a run of unattached strings
8
+ * and the listener has to infer which value belongs to which term. `dl`,
9
+ * `dt` and `dd` state that relationship in the markup, and it is the whole
10
+ * reason this component exists.
11
+ *
12
+ * A long value wraps. It is never truncated, because a clipped identifier
13
+ * cannot be read out, cannot be selected and cannot be copied, and an id or
14
+ * a key is exactly the kind of value a detail page is opened for. `break-words`
15
+ * covers the unbroken ones, so a token or a hash wraps instead of pushing the
16
+ * page sideways.
17
+ */
18
+ import type { Snippet } from 'svelte';
19
+ interface Item {
20
+ term: string;
21
+ value: string;
22
+ /** Rendered instead of value when present. */
23
+ detail?: Snippet;
24
+ }
25
+ type Layout = 'inline' | 'stacked';
26
+ interface Props {
27
+ items: Item[];
28
+ /** 'inline' puts the term and value on one row, 'stacked' puts the value under the term. */
29
+ layout?: Layout;
30
+ class?: string;
31
+ }
32
+ declare const DescriptionList: import("svelte").Component<Props, {}, "">;
33
+ type DescriptionList = ReturnType<typeof DescriptionList>;
34
+ export default DescriptionList;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { overlay } from '../internal/overlay.js';
3
4
 
4
5
  type Side = 'left' | 'right';
5
6
  type Size = 'sm' | 'md' | 'lg' | 'xl';
@@ -33,6 +34,9 @@
33
34
  xl: 'w-[480px]',
34
35
  };
35
36
 
37
+ const headingId = $props.id();
38
+ const descriptionId = `${headingId}-description`;
39
+
36
40
  function close() {
37
41
  open = false;
38
42
  onclose?.();
@@ -52,25 +56,32 @@
52
56
  <div class="fixed inset-0 z-50 flex {side === 'right' ? 'justify-end' : 'justify-start'}">
53
57
  <button
54
58
  type="button"
59
+ tabindex="-1"
60
+ aria-hidden="true"
55
61
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
56
- aria-label="Close"
57
62
  onclick={close}
58
63
  ></button>
59
64
 
60
65
  <div
61
- class="relative flex h-full flex-col {widths[size]} bg-surface shadow-2xl
66
+ use:overlay
67
+ class="relative flex h-full max-w-full flex-col {widths[size]} bg-surface shadow-2xl
62
68
  {side === 'right' ? 'border-l' : 'border-r'} border-line
63
69
  {side === 'right'
64
70
  ? 'animate-[drawer-in-right_150ms_ease-out]'
65
71
  : 'animate-[drawer-in-left_150ms_ease-out]'}"
66
72
  role="dialog"
67
73
  aria-modal="true"
74
+ aria-labelledby={title ? headingId : undefined}
75
+ aria-describedby={description ? descriptionId : undefined}
76
+ aria-label={title ? undefined : 'Panel'}
68
77
  >
69
78
  {#if title}
70
79
  <div class="flex shrink-0 items-start justify-between gap-4 border-b border-line px-5 py-4">
71
80
  <div>
72
- <h2 class="font-semibold text-fg">{title}</h2>
73
- {#if description}<p class="mt-0.5 text-sm text-muted">{description}</p>{/if}
81
+ <h2 id={headingId} class="font-semibold text-fg">{title}</h2>
82
+ {#if description}
83
+ <p id={descriptionId} class="mt-0.5 text-sm text-muted">{description}</p>
84
+ {/if}
74
85
  </div>
75
86
  <button
76
87
  type="button"
@@ -0,0 +1,104 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * What Field hands the control it wraps.
4
+ *
5
+ * The wiring is the whole point of the component. A consumer building a
6
+ * control the kit does not ship - a colour well, a code editor, a third
7
+ * party map picker - cannot reach internal/field.ts, so it hand-copies the
8
+ * class strings and stops there: a form in a consuming app spelled
9
+ * `class="flex flex-col gap-1.5"` inline, which is the literal value of
10
+ * FIELD_WRAP, and then wired nothing. The control had no id, the label had
11
+ * no `for`, and the error paragraph under it was never announced.
12
+ */
13
+ export interface FieldWiring {
14
+ /** The id the control must carry. The label's `for` already points at it. */
15
+ id: string;
16
+ /**
17
+ * The id of whichever message is on screen, for the control's
18
+ * `aria-describedby`. Undefined when there is neither a hint nor an error,
19
+ * so the attribute is left off rather than pointing at nothing.
20
+ */
21
+ describedBy: string | undefined;
22
+ /** True while an error is showing, for the control's `aria-invalid`. */
23
+ invalid: boolean;
24
+ }
25
+ </script>
26
+
27
+ <script lang="ts">
28
+ import type { Snippet } from 'svelte';
29
+ import {
30
+ FIELD_ERROR,
31
+ FIELD_HINT,
32
+ FIELD_LABEL,
33
+ FIELD_WRAP,
34
+ describedBy,
35
+ } from '../internal/field.js';
36
+
37
+ interface Props {
38
+ /** Overrides the generated id, for a control the caller has to address by name. */
39
+ id?: string;
40
+ label?: string;
41
+ /** Screen-reader-only label, for a control whose visible name is a table column header. */
42
+ labelHidden?: boolean;
43
+ hint?: string;
44
+ /** Replaces the hint while it is set, and marks the wiring invalid. */
45
+ error?: string;
46
+ /**
47
+ * Draws the marker beside the label. Set `required` on the control as well:
48
+ * the marker is decorative and the control's own attribute is what a screen
49
+ * reader reports.
50
+ */
51
+ required?: boolean;
52
+ class?: string;
53
+ /** Receives what the control must carry so the message row actually reaches a screen reader. */
54
+ children: Snippet<[FieldWiring]>;
55
+ }
56
+
57
+ let {
58
+ id = undefined,
59
+ label = undefined,
60
+ labelHidden = false,
61
+ hint = undefined,
62
+ error = undefined,
63
+ required = false,
64
+ class: klass = '',
65
+ children,
66
+ }: Props = $props();
67
+
68
+ /*
69
+ * $props.id(), never Math.random(). A random id is one value in the server
70
+ * render and a different one on hydration, so the label's `for` and the id
71
+ * the control was given stop matching the moment the client takes over, and
72
+ * clicking the label focuses nothing.
73
+ */
74
+ const uid = $props.id();
75
+ const fieldId = $derived(id ?? uid);
76
+
77
+ const wiring: FieldWiring = $derived({
78
+ id: fieldId,
79
+ describedBy: describedBy(fieldId, error, hint),
80
+ invalid: !!error,
81
+ });
82
+ </script>
83
+
84
+ <div class="{FIELD_WRAP} {klass}">
85
+ {#if label}
86
+ <!--
87
+ A hidden label stays a real label with a real `for`. Swapping it for an
88
+ aria-label on the control would name the control and drop the name out of
89
+ the reading order, so a reader moving through a row of them would meet
90
+ the controls with nothing ahead of them saying what each one is.
91
+ -->
92
+ <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
93
+ {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
94
+ </label>
95
+ {/if}
96
+
97
+ {@render children(wiring)}
98
+
99
+ {#if error}
100
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
101
+ {:else if hint}
102
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
103
+ {/if}
104
+ </div>
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What Field hands the control it wraps.
3
+ *
4
+ * The wiring is the whole point of the component. A consumer building a
5
+ * control the kit does not ship - a colour well, a code editor, a third
6
+ * party map picker - cannot reach internal/field.ts, so it hand-copies the
7
+ * class strings and stops there: a form in a consuming app spelled
8
+ * `class="flex flex-col gap-1.5"` inline, which is the literal value of
9
+ * FIELD_WRAP, and then wired nothing. The control had no id, the label had
10
+ * no `for`, and the error paragraph under it was never announced.
11
+ */
12
+ export interface FieldWiring {
13
+ /** The id the control must carry. The label's `for` already points at it. */
14
+ id: string;
15
+ /**
16
+ * The id of whichever message is on screen, for the control's
17
+ * `aria-describedby`. Undefined when there is neither a hint nor an error,
18
+ * so the attribute is left off rather than pointing at nothing.
19
+ */
20
+ describedBy: string | undefined;
21
+ /** True while an error is showing, for the control's `aria-invalid`. */
22
+ invalid: boolean;
23
+ }
24
+ import type { Snippet } from 'svelte';
25
+ interface Props {
26
+ /** Overrides the generated id, for a control the caller has to address by name. */
27
+ id?: string;
28
+ label?: string;
29
+ /** Screen-reader-only label, for a control whose visible name is a table column header. */
30
+ labelHidden?: boolean;
31
+ hint?: string;
32
+ /** Replaces the hint while it is set, and marks the wiring invalid. */
33
+ error?: string;
34
+ /**
35
+ * Draws the marker beside the label. Set `required` on the control as well:
36
+ * the marker is decorative and the control's own attribute is what a screen
37
+ * reader reports.
38
+ */
39
+ required?: boolean;
40
+ class?: string;
41
+ /** Receives what the control must carry so the message row actually reaches a screen reader. */
42
+ children: Snippet<[FieldWiring]>;
43
+ }
44
+ declare const Field: import("svelte").Component<Props, {}, "">;
45
+ type Field = ReturnType<typeof Field>;
46
+ export default Field;
@@ -51,7 +51,10 @@
51
51
  <label for={id} class={FIELD_LABEL}>{label}</label>
52
52
  {/if}
53
53
 
54
- <!-- svelte-ignore a11y_no_static_element_interactions -->
54
+ <!-- The dashed border is the only thing marking this region as a control, so
55
+ it rests on line-strong: line reads 1.25:1 and failed SC 1.4.11. The
56
+ hover is full-strength brand, because a focus-adjacent colour dropped to
57
+ half alpha reads as a weaker affordance for no reason a user can infer. -->
55
58
  <label
56
59
  class="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-xl
57
60
  border-2 border-dashed px-4 py-8 text-center transition-colors duration-150
@@ -59,7 +62,7 @@
59
62
  ? 'border-danger bg-danger/5 hover:bg-danger/8'
60
63
  : dragOver
61
64
  ? 'border-brand bg-brand/8'
62
- : 'border-line bg-surface-2/40 hover:border-brand/50 hover:bg-brand/5'}
65
+ : 'border-line-strong bg-surface-2/40 hover:border-brand hover:bg-brand/5'}
63
66
  {disabled ? 'cursor-not-allowed opacity-50' : ''}"
64
67
  ondragover={(e) => {
65
68
  e.preventDefault();
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { TONE_GLYPH } from '../internal/tone.js';
4
+
5
+ /** The two outcomes a submit has. Anything else is a hint, not a result. */
6
+ type Tone = 'danger' | 'success';
7
+
8
+ interface Props {
9
+ tone?: Tone;
10
+ class?: string;
11
+ children: Snippet;
12
+ }
13
+
14
+ let { tone = 'danger', class: klass = '', children }: Props = $props();
15
+
16
+ /**
17
+ * The message colour, stated once.
18
+ *
19
+ * Eleven pages rendered a submit outcome into a bare paragraph and picked
20
+ * their own size and colour for it, so the same failure read as three
21
+ * different things depending on which settings page the user was on.
22
+ */
23
+ const tones: Record<Tone, string> = {
24
+ danger: 'text-danger',
25
+ success: 'text-success',
26
+ };
27
+
28
+ /**
29
+ * A live region reports a CHANGE to what it holds. A region that arrives in
30
+ * the document with its text already inside it is not a change, and nothing
31
+ * is read out: that is why every toast was silent while each toast carried
32
+ * its own role="status". Toaster fixed it by keeping one region mounted for
33
+ * the life of the app and moving each toast into it.
34
+ *
35
+ * A submit outcome has no such permanent host, because the page mounts this
36
+ * component at the moment the submit resolves. So the region goes in empty
37
+ * and the message is placed inside it on the next frame, which is the same
38
+ * shape of change a toast makes.
39
+ *
40
+ * The server runs no effect and has no frames, so it renders the message
41
+ * straight away: a page delivered without its client bundle still carries
42
+ * the outcome of the submit, which matters more than announcing it.
43
+ */
44
+ let filled = $state(typeof window === 'undefined');
45
+
46
+ $effect(() => {
47
+ const frame = requestAnimationFrame(() => (filled = true));
48
+ return () => cancelAnimationFrame(frame);
49
+ });
50
+ </script>
51
+
52
+ <!--
53
+ The tones differ in more than colour. A failure takes role="alert", which
54
+ interrupts whatever the reader is doing, because the submit did not go
55
+ through and the user has to act on it. A confirmation takes aria-live
56
+ "polite" and waits for a pause, because the work is done and nothing is being
57
+ asked of anyone.
58
+ -->
59
+ <div
60
+ class="flex items-start gap-2 text-sm {tones[tone]} {klass}"
61
+ role={tone === 'danger' ? 'alert' : 'status'}
62
+ aria-live={tone === 'danger' ? 'assertive' : 'polite'}
63
+ >
64
+ {#if filled}
65
+ <!-- Stroked SVG, not a literal cross or tick: a font glyph lands at
66
+ whatever weight the reader's font gives it, which sat visibly lighter
67
+ than every other icon in the library. Colour alone would also be the
68
+ only thing telling a failure from a confirmation. -->
69
+ <svg
70
+ class="mt-0.5 shrink-0"
71
+ width="15"
72
+ height="15"
73
+ viewBox="0 0 24 24"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ stroke-width="2.5"
77
+ stroke-linecap="round"
78
+ stroke-linejoin="round"
79
+ aria-hidden="true"
80
+ >
81
+ <path d={TONE_GLYPH[tone]} />
82
+ </svg>
83
+ <p class="min-w-0 flex-1">{@render children()}</p>
84
+ {/if}
85
+ </div>
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from 'svelte';
2
+ /** The two outcomes a submit has. Anything else is a hint, not a result. */
3
+ type Tone = 'danger' | 'success';
4
+ interface Props {
5
+ tone?: Tone;
6
+ class?: string;
7
+ children: Snippet;
8
+ }
9
+ declare const FormMessage: import("svelte").Component<Props, {}, "">;
10
+ type FormMessage = ReturnType<typeof FormMessage>;
11
+ export default FormMessage;
@@ -54,7 +54,7 @@
54
54
  <div class="{FIELD_WRAP} {klass}">
55
55
  {#if label}
56
56
  <label for={fieldId} class={FIELD_LABEL}>
57
- {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
57
+ {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
58
58
  </label>
59
59
  {/if}
60
60
 
@@ -9,6 +9,12 @@
9
9
  children,
10
10
  }: {
11
11
  for?: string;
12
+ /**
13
+ * Draws the marker beside the text. Set `required` on the control this
14
+ * label points at as well: the marker is decorative, and the control's own
15
+ * attribute is what a screen reader reports. A label is read in full to
16
+ * name what it points at, so anything said here lands in that name.
17
+ */
12
18
  required?: boolean;
13
19
  hint?: string;
14
20
  class?: string;
@@ -22,7 +28,7 @@
22
28
  >
23
29
  {@render children()}
24
30
  {#if required}
25
- <span class="text-danger font-bold leading-none" aria-label="required">*</span>
31
+ <span class="text-danger font-bold leading-none" aria-hidden="true">*</span>
26
32
  {/if}
27
33
  {#if hint}
28
34
  <span class="text-faint normal-case font-normal ml-0.5">({hint})</span>
@@ -1,6 +1,12 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  for?: string;
4
+ /**
5
+ * Draws the marker beside the text. Set `required` on the control this
6
+ * label points at as well: the marker is decorative, and the control's own
7
+ * attribute is what a screen reader reports. A label is read in full to
8
+ * name what it points at, so anything said here lands in that name.
9
+ */
4
10
  required?: boolean;
5
11
  hint?: string;
6
12
  class?: string;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { overlay } from '../internal/overlay.js';
3
4
 
4
5
  type Size = 'sm' | 'md' | 'lg';
5
6
 
@@ -29,6 +30,11 @@
29
30
  lg: 'max-w-2xl',
30
31
  };
31
32
 
33
+ // aria-labelledby needs an id that is unique per instance, because two modals
34
+ // can be mounted at once while one animates out.
35
+ const headingId = $props.id();
36
+ const descriptionId = `${headingId}-description`;
37
+
32
38
  function close() {
33
39
  open = false;
34
40
  onclose?.();
@@ -48,27 +54,38 @@
48
54
  <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
49
55
  <button
50
56
  type="button"
57
+ tabindex="-1"
58
+ aria-hidden="true"
51
59
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
52
- aria-label="Close"
53
60
  onclick={close}
54
61
  ></button>
55
62
 
56
63
  <div
57
- class="relative w-full {widths[size]} rounded-xl border border-line bg-surface shadow-2xl
64
+ use:overlay
65
+ class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
66
+ overflow-hidden rounded-xl border border-line bg-surface shadow-2xl
58
67
  animate-[modal-in_120ms_ease-out]"
59
68
  role="dialog"
60
69
  aria-modal="true"
70
+ aria-labelledby={title ? headingId : undefined}
71
+ aria-describedby={description ? descriptionId : undefined}
72
+ aria-label={title ? undefined : 'Dialog'}
61
73
  >
62
74
  {#if title}
63
- <div class="flex items-start justify-between gap-4 px-5 py-4 border-b border-line">
75
+ <div
76
+ class="flex shrink-0 items-start justify-between gap-4 px-5 py-4 border-b border-line"
77
+ >
64
78
  <div>
65
- <h2 class="font-semibold text-fg">{title}</h2>
66
- {#if description}<p class="text-sm text-muted mt-0.5">{description}</p>{/if}
79
+ <h2 id={headingId} class="font-semibold text-fg">{title}</h2>
80
+ {#if description}
81
+ <p id={descriptionId} class="text-sm text-muted mt-0.5">{description}</p>
82
+ {/if}
67
83
  </div>
68
84
  <button
69
85
  type="button"
70
86
  onclick={close}
71
- class="shrink-0 text-faint transition-colors duration-150 hover:text-fg"
87
+ class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg
88
+ outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
72
89
  aria-label="Close"
73
90
  >
74
91
  <svg
@@ -85,13 +102,13 @@
85
102
  </div>
86
103
  {/if}
87
104
 
88
- <div class="px-5 py-4">
105
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
89
106
  {@render children()}
90
107
  </div>
91
108
 
92
109
  {#if footer}
93
110
  <div
94
- class="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface-2/40"
111
+ class="flex shrink-0 items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface-2/40"
95
112
  >
96
113
  {@render footer()}
97
114
  </div>