@inglorious/ui 6.0.0 → 7.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 (39) hide show
  1. package/package.json +32 -12
  2. package/src/controls/controls.css +3 -1
  3. package/src/controls/file-upload/file-upload.stories.js +93 -0
  4. package/src/controls/file-upload/index.js +3 -0
  5. package/src/controls/file-upload/style.css +99 -0
  6. package/src/controls/file-upload/template.js +100 -0
  7. package/src/controls/input/input.stories.js +110 -3
  8. package/src/controls/textarea/index.js +3 -0
  9. package/src/controls/textarea/style.css +98 -0
  10. package/src/controls/textarea/template.js +94 -0
  11. package/src/controls/textarea/textarea.stories.js +158 -0
  12. package/src/data-display/before-after/before-after.stories.js +74 -0
  13. package/src/data-display/before-after/handlers.js +44 -0
  14. package/src/data-display/before-after/helpers.js +4 -0
  15. package/src/data-display/before-after/index.js +17 -0
  16. package/src/data-display/before-after/style.css +128 -0
  17. package/src/data-display/before-after/template.js +225 -0
  18. package/src/data-display/carousel/carousel.stories.js +166 -0
  19. package/src/data-display/carousel/handlers.js +78 -0
  20. package/src/data-display/carousel/helpers.js +54 -0
  21. package/src/data-display/carousel/index.js +17 -0
  22. package/src/data-display/carousel/style.css +233 -0
  23. package/src/data-display/carousel/template.js +655 -0
  24. package/src/data-display/data-display.css +2 -0
  25. package/src/examples/dashboard/app-drawer.js +12 -0
  26. package/src/examples/dashboard/before-after-data.js +29 -0
  27. package/src/examples/dashboard/primitive-content.js +220 -33
  28. package/src/index.js +4 -0
  29. package/src/layout/flex/flex.stories.js +49 -0
  30. package/src/layout/flex/style.css +59 -0
  31. package/src/layout/flex/template.js +2 -0
  32. package/types/controls/file-upload.d.ts +27 -0
  33. package/types/controls/input.d.ts +6 -0
  34. package/types/controls/textarea.d.ts +29 -0
  35. package/types/controls/upload-field.d.ts +27 -0
  36. package/types/data-display/before-after.d.ts +39 -0
  37. package/types/data-display/carousel.d.ts +72 -0
  38. package/types/index.d.ts +2 -0
  39. package/types/layout/flex.d.ts +1 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @typedef {import('../../../types/data-display/before-after.js').BeforeAfterProps} BeforeAfterProps
3
+ * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
4
+ */
5
+
6
+ import { clamp } from "@inglorious/utils/math/numbers.js"
7
+ import { html } from "@inglorious/web"
8
+ import { classMap } from "@inglorious/web/directives/class-map"
9
+
10
+ import {
11
+ DEFAULT_POSITION,
12
+ DEFAULT_STEP,
13
+ MAX_POSITION,
14
+ MIN_POSITION,
15
+ } from "./helpers.js"
16
+
17
+ const PRIMARY_BUTTON = 1
18
+
19
+ export const BeforeAfter = {
20
+ /**
21
+ * Main entrypoint for the before/after template. It delegates to the base renderer so overrides can still reuse it.
22
+ * Before/after overlays two images and reveals the top one with a draggable divider.
23
+ * @param {BeforeAfterProps} props The before/after props.
24
+ * @returns {TemplateResult} The rendered control.
25
+ */
26
+ render(props) {
27
+ return this.renderBeforeAfter(props)
28
+ },
29
+
30
+ /**
31
+ * Renders the container and wires the pointer and keyboard interactions.
32
+ * This is the canonical renderer that custom `render()` overrides should call.
33
+ * @param {BeforeAfterProps} props The before/after props.
34
+ * @returns {TemplateResult} The rendered control.
35
+ */
36
+ renderBeforeAfter(props) {
37
+ const {
38
+ label,
39
+ position = DEFAULT_POSITION,
40
+ isDisabled = false,
41
+ isFullWidth = false,
42
+ } = props
43
+
44
+ const classes = {
45
+ "iw-before-after": true,
46
+ "iw-before-after-full-width": isFullWidth,
47
+ "iw-before-after-disabled": isDisabled,
48
+ }
49
+
50
+ return html`<div
51
+ class=${classMap(classes)}
52
+ style="--iw-before-after-position: ${position}%;"
53
+ role="slider"
54
+ tabindex=${isDisabled ? -1 : 0}
55
+ aria-label=${label || "Before and after comparison"}
56
+ aria-orientation="horizontal"
57
+ aria-valuemin=${MIN_POSITION}
58
+ aria-valuemax=${MAX_POSITION}
59
+ aria-valuenow=${Math.round(position)}
60
+ aria-disabled=${isDisabled}
61
+ @pointerdown=${handlePointerDown(props)}
62
+ @pointermove=${handlePointerMove(props)}
63
+ @pointerup=${handlePointerUp}
64
+ @pointercancel=${handlePointerUp}
65
+ @keydown=${handleKeyDown(props)}
66
+ >
67
+ ${this.renderBefore?.(props)} ${this.renderAfter?.(props)}
68
+ ${this.renderDivider?.(props)}
69
+ </div>`
70
+ },
71
+
72
+ /**
73
+ * Renders the bottom layer, fully visible underneath the clipped one.
74
+ * It also gives the control its intrinsic size.
75
+ * @param {BeforeAfterProps} props The before/after props.
76
+ * @returns {TemplateResult} The rendered layer.
77
+ */
78
+ renderBefore(props) {
79
+ return html`<div class="iw-before-after-item iw-before-after-before">
80
+ <img
81
+ class="iw-before-after-image"
82
+ src=${props.before?.src ?? ""}
83
+ alt=${props.before?.alt ?? ""}
84
+ draggable="false"
85
+ />
86
+ </div>`
87
+ },
88
+
89
+ /**
90
+ * Renders the top layer, clipped to the current divider position through CSS.
91
+ * @param {BeforeAfterProps} props The before/after props.
92
+ * @returns {TemplateResult} The rendered layer.
93
+ */
94
+ renderAfter(props) {
95
+ return html`<div class="iw-before-after-item iw-before-after-after">
96
+ <img
97
+ class="iw-before-after-image"
98
+ src=${props.after?.src ?? ""}
99
+ alt=${props.after?.alt ?? ""}
100
+ draggable="false"
101
+ />
102
+ </div>`
103
+ },
104
+
105
+ /**
106
+ * Renders the vertical bar sitting on the seam between the two layers.
107
+ * Override this to change the separator, or drop the handle entirely.
108
+ * @param {BeforeAfterProps} props The before/after props.
109
+ * @returns {TemplateResult} The rendered divider.
110
+ */
111
+ renderDivider(props) {
112
+ return html`<div class="iw-before-after-divider" aria-hidden="true">
113
+ ${this.renderHandle?.(props)}
114
+ </div>`
115
+ },
116
+
117
+ /**
118
+ * Renders the grip shown at the centre of the divider.
119
+ * Override this to supply a custom handle.
120
+ * @returns {TemplateResult} The rendered handle.
121
+ */
122
+ renderHandle() {
123
+ return html`<div class="iw-before-after-handle"></div>`
124
+ },
125
+ }
126
+
127
+ /**
128
+ * Start dragging: capture the pointer so moves keep tracking outside the box.
129
+ * @param {BeforeAfterProps} props
130
+ * @returns {(event: PointerEvent) => void}
131
+ */
132
+ function handlePointerDown(props) {
133
+ return (event) => {
134
+ if (props.isDisabled) return
135
+
136
+ event.currentTarget.setPointerCapture?.(event.pointerId)
137
+ move(props, event)
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Track the divider while the primary button or contact is held down.
143
+ * @param {BeforeAfterProps} props
144
+ * @returns {(event: PointerEvent) => void}
145
+ */
146
+ function handlePointerMove(props) {
147
+ return (event) => {
148
+ if (props.isDisabled) return
149
+ if (!(event.buttons & PRIMARY_BUTTON)) return
150
+
151
+ move(props, event)
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Stop dragging.
157
+ * @param {PointerEvent} event
158
+ */
159
+ function handlePointerUp(event) {
160
+ event.currentTarget.releasePointerCapture?.(event.pointerId)
161
+ }
162
+
163
+ /**
164
+ * Move the divider with the arrow keys, or jump to either edge.
165
+ * @param {BeforeAfterProps} props
166
+ * @returns {(event: KeyboardEvent) => void}
167
+ */
168
+ function handleKeyDown(props) {
169
+ return (event) => {
170
+ if (props.isDisabled) return
171
+
172
+ const position = props.position ?? DEFAULT_POSITION
173
+ const step = props.step ?? DEFAULT_STEP
174
+
175
+ let next
176
+ switch (event.key) {
177
+ case "ArrowLeft":
178
+ case "ArrowDown":
179
+ next = position - step
180
+ break
181
+ case "ArrowRight":
182
+ case "ArrowUp":
183
+ next = position + step
184
+ break
185
+ case "Home":
186
+ next = MIN_POSITION
187
+ break
188
+ case "End":
189
+ next = MAX_POSITION
190
+ break
191
+ default:
192
+ return
193
+ }
194
+
195
+ event.preventDefault()
196
+ props.onPositionChange?.(clamp(next, MIN_POSITION, MAX_POSITION))
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Report the divider position under the pointer.
202
+ * @param {BeforeAfterProps} props
203
+ * @param {PointerEvent} event
204
+ */
205
+ function move(props, event) {
206
+ const position = positionFromEvent(event)
207
+ if (position != null) props.onPositionChange?.(position)
208
+ }
209
+
210
+ /**
211
+ * Convert a pointer event into a divider position (0-100) relative to the
212
+ * element the listener is attached to. Null when it has no measurable width.
213
+ * @param {PointerEvent} event
214
+ * @returns {number | null}
215
+ */
216
+ function positionFromEvent(event) {
217
+ const rect = event.currentTarget.getBoundingClientRect()
218
+ if (!rect.width) return null
219
+
220
+ return clamp(
221
+ ((event.clientX - rect.left) / rect.width) * 100,
222
+ MIN_POSITION,
223
+ MAX_POSITION,
224
+ )
225
+ }
@@ -0,0 +1,166 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ import {
4
+ createEntityRender,
5
+ notifyActionArgType,
6
+ } from "../../stories/notifyStory.js"
7
+ import { Carousel } from "./index.js"
8
+
9
+ const COLORS = ["#22d3ee", "#4f46e5", "#f59e0b", "#10b981", "#ef4444"]
10
+
11
+ const slide = (index) =>
12
+ html`<div
13
+ style="display:grid;place-items:center;height:14rem;border-radius:0.5rem;
14
+ background:${COLORS[index]};color:#fff;font:700 2rem sans-serif;"
15
+ >
16
+ ${index + 1}
17
+ </div>`
18
+
19
+ const items = COLORS.map((color, index) => slide(index))
20
+
21
+ export default {
22
+ title: "Data Display/Carousel",
23
+ tags: ["autodocs"],
24
+ render: createEntityRender({ Carousel }),
25
+ argTypes: {
26
+ ...notifyActionArgType,
27
+ label: { control: "text", description: "Accessible label." },
28
+ page: {
29
+ control: "number",
30
+ description: "Index of the item the viewport settled on.",
31
+ },
32
+ axis: {
33
+ control: "inline-radio",
34
+ options: ["x", "y"],
35
+ description: "Scroll direction.",
36
+ },
37
+ gap: {
38
+ control: "select",
39
+ options: ["none", "sm", "md", "lg", "xl"],
40
+ description: "Spacing between items.",
41
+ },
42
+ align: {
43
+ control: "select",
44
+ options: ["stretch", "start", "center", "end"],
45
+ description: "Off-axis alignment of items.",
46
+ },
47
+ arrowPlacement: {
48
+ control: "inline-radio",
49
+ options: ["inside", "outside"],
50
+ description: "Float the arrows over the slides, or sit them beside.",
51
+ },
52
+ arrowVariant: {
53
+ control: "select",
54
+ options: ["default", "outline", "ghost"],
55
+ description: "Button variant used for the arrows.",
56
+ },
57
+ arrowColor: {
58
+ control: "select",
59
+ options: ["primary", "secondary", "success", "warning", "error", "info"],
60
+ description: "Button color used for the arrows.",
61
+ },
62
+ isInfinite: {
63
+ control: "boolean",
64
+ description: "Wrap past the ends by rotating the items.",
65
+ },
66
+ hasArrows: {
67
+ control: "boolean",
68
+ description: "Show previous/next arrows.",
69
+ },
70
+ hasIndicators: { control: "boolean", description: "Show pagination dots." },
71
+ isFullWidth: {
72
+ control: "boolean",
73
+ description: "Expand to container width.",
74
+ },
75
+ },
76
+ parameters: {
77
+ docs: {
78
+ description: {
79
+ component:
80
+ "Carousel built on CSS scroll snapping: swiping, momentum and snapping come from the browser, while the entity holds the current page. Drag, use the arrows or dots, or focus the viewport and use the arrow keys / Home / End.",
81
+ },
82
+ },
83
+ },
84
+ }
85
+
86
+ export const Default = {}
87
+ Default.args = {
88
+ id: "carousel",
89
+ type: "Carousel",
90
+ label: "Example carousel",
91
+ items,
92
+ page: 0,
93
+ axis: "x",
94
+ gap: "md",
95
+ align: "stretch",
96
+ arrowPlacement: "inside",
97
+ arrowVariant: "default",
98
+ isInfinite: true,
99
+ hasArrows: true,
100
+ hasIndicators: true,
101
+ isFullWidth: true,
102
+ }
103
+
104
+ /** Arrows beside the scrolling area, as most carousels show them. */
105
+ export const ArrowsOutside = {}
106
+ ArrowsOutside.args = {
107
+ ...Default.args,
108
+ id: "carousel-arrows-outside",
109
+ arrowPlacement: "outside",
110
+ arrowVariant: "ghost",
111
+ }
112
+
113
+ /** Wraps past the ends: after 5 it continues to 1, rather than rewinding. */
114
+ export const Infinite = {}
115
+ Infinite.args = {
116
+ ...Default.args,
117
+ id: "carousel-infinite",
118
+ isInfinite: true,
119
+ }
120
+
121
+ export const StartOnThirdSlide = {}
122
+ StartOnThirdSlide.args = {
123
+ ...Default.args,
124
+ id: "carousel-third",
125
+ page: 2,
126
+ }
127
+
128
+ /**
129
+ * Vertical carousels need a height to scroll within: set
130
+ * `--iw-carousel-viewport-size` (it defaults to `20rem`).
131
+ */
132
+ export const Vertical = {}
133
+ Vertical.args = {
134
+ ...Default.args,
135
+ id: "carousel-vertical",
136
+ axis: "y",
137
+ }
138
+ Vertical.decorators = [
139
+ (story) =>
140
+ html`<div style="--iw-carousel-viewport-size: 14rem">${story()}</div>`,
141
+ ]
142
+
143
+ export const Bare = {}
144
+ Bare.args = {
145
+ ...Default.args,
146
+ id: "carousel-bare",
147
+ hasArrows: false,
148
+ hasIndicators: false,
149
+ }
150
+
151
+ /**
152
+ * Several items at once, by overriding `--iw-carousel-item-size`.
153
+ * Trailing items share the final scroll position, so the last pages settle
154
+ * together — one item per view is the exact case.
155
+ */
156
+ export const ManyPerView = {}
157
+ ManyPerView.args = {
158
+ ...Default.args,
159
+ id: "carousel-many",
160
+ // The infinite treadmill keeps one slide at its centre, so it is not a good
161
+ // fit when several slides are intentionally visible at once.
162
+ isInfinite: false,
163
+ }
164
+ ManyPerView.decorators = [
165
+ (story) => html`<div style="--iw-carousel-item-size: 14rem">${story()}</div>`,
166
+ ]
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @typedef {import('../../../types/data-display/carousel.js').CarouselProps} CarouselEntity
3
+ */
4
+
5
+ import {
6
+ clampPage,
7
+ DEFAULT_ALIGN,
8
+ DEFAULT_ARROW_PLACEMENT,
9
+ DEFAULT_ARROW_VARIANT,
10
+ DEFAULT_AXIS,
11
+ DEFAULT_GAP,
12
+ FIRST_PAGE,
13
+ NO_ROTATION,
14
+ normalizeRotation,
15
+ } from "./helpers.js"
16
+
17
+ const ENOUGH_TO_WRAP = 1
18
+
19
+ /**
20
+ * Initialize missing carousel state defaults.
21
+ * The only persistent piece of state is `page`: the index of the item the
22
+ * viewport has settled on. Everything else is configuration.
23
+ * @param {CarouselEntity} entity
24
+ */
25
+ export function create(entity) {
26
+ entity.items ??= []
27
+ entity.page ??= FIRST_PAGE
28
+ entity.rotation ??= NO_ROTATION
29
+
30
+ entity.isInfinite ??= false
31
+ entity.axis ??= DEFAULT_AXIS
32
+ entity.gap ??= DEFAULT_GAP
33
+ entity.align ??= DEFAULT_ALIGN
34
+
35
+ entity.arrowPlacement ??= DEFAULT_ARROW_PLACEMENT
36
+ entity.arrowVariant ??= DEFAULT_ARROW_VARIANT
37
+
38
+ entity.label ??= ""
39
+ entity.hasArrows ??= true
40
+ entity.hasIndicators ??= true
41
+ entity.isFullWidth ??= false
42
+
43
+ // An infinite carousel keeps the current item in the middle slot, so the
44
+ // starting item is placed there by rotating the strip up front.
45
+ if (entity.isInfinite && entity.items.length > ENOUGH_TO_WRAP) {
46
+ const home = Math.floor(entity.items.length / 2)
47
+ entity.rotation = normalizeRotation(entity.page - home, entity.items.length)
48
+ entity.page = home
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Settle on another page, clamped to the available items.
54
+ * @param {CarouselEntity} entity
55
+ * @param {number} page
56
+ */
57
+ export function pageChange(entity, page) {
58
+ const value = Number(page)
59
+ if (Number.isNaN(value)) return
60
+
61
+ entity.page = clampPage(value, entity.items)
62
+ }
63
+
64
+ /**
65
+ * Rotate the items by `step` (wrapping), which is how an infinite carousel
66
+ * continues forward past the end instead of rewinding to the start.
67
+ * @param {CarouselEntity} entity
68
+ * @param {number} step
69
+ */
70
+ export function rotate(entity, step) {
71
+ const value = Number(step)
72
+ if (Number.isNaN(value)) return
73
+
74
+ entity.rotation = normalizeRotation(
75
+ (entity.rotation ?? NO_ROTATION) + value,
76
+ entity.items?.length ?? 0,
77
+ )
78
+ }
@@ -0,0 +1,54 @@
1
+ import { clamp } from "@inglorious/utils/math/numbers.js"
2
+
3
+ export const DEFAULT_AXIS = "x"
4
+ export const VERTICAL_AXIS = "y"
5
+ export const DEFAULT_GAP = "none"
6
+ export const DEFAULT_ALIGN = "stretch"
7
+ export const DEFAULT_ARROW_PLACEMENT = "inside"
8
+ export const DEFAULT_ARROW_VARIANT = "default"
9
+ export const FIRST_PAGE = 0
10
+ export const NO_ROTATION = 0
11
+
12
+ /**
13
+ * Bring a rotation into the `0 .. len - 1` range, wrapping negatives.
14
+ * @param {number} rotation
15
+ * @param {number} length
16
+ * @returns {number}
17
+ */
18
+ export function normalizeRotation(rotation, length) {
19
+ if (length <= 0) return NO_ROTATION
20
+ return ((rotation % length) + length) % length
21
+ }
22
+
23
+ /**
24
+ * Rotate items so that `items[rotation]` becomes the first one — this is how an
25
+ * infinite carousel turns `[1, 2, 3, 4, 5]` into `[2, 3, 4, 5, 1]` instead of
26
+ * rewinding all the way back to the start.
27
+ * @param {unknown[]} [items]
28
+ * @param {number} [rotation]
29
+ * @returns {unknown[]}
30
+ */
31
+ export function rotateItems(items = [], rotation = NO_ROTATION) {
32
+ if (!items.length) return items
33
+ const at = normalizeRotation(rotation, items.length)
34
+ return items.slice(at).concat(items.slice(0, at))
35
+ }
36
+
37
+ /**
38
+ * Get the index of the last available page.
39
+ * @param {unknown[]} [items]
40
+ * @returns {number}
41
+ */
42
+ export function getLastPage(items = []) {
43
+ return Math.max(FIRST_PAGE, items.length - 1)
44
+ }
45
+
46
+ /**
47
+ * Keep a page index within the available pages.
48
+ * @param {number} page
49
+ * @param {unknown[]} [items]
50
+ * @returns {number}
51
+ */
52
+ export function clampPage(page, items) {
53
+ return clamp(page, FIRST_PAGE, getLastPage(items))
54
+ }
@@ -0,0 +1,17 @@
1
+ import * as handlers from "./handlers.js"
2
+ import { Carousel as renderers } from "./template.js"
3
+
4
+ export const Carousel = {
5
+ ...handlers,
6
+ ...renderers,
7
+ render(entity, api) {
8
+ const id = entity.id
9
+ const props = {
10
+ ...entity,
11
+ onPageChange: (page) => api.notify(`#${id}:pageChange`, page),
12
+ onRotate: (step) => api.notify(`#${id}:rotate`, step),
13
+ }
14
+
15
+ return this.renderCarousel(props)
16
+ },
17
+ }